/** * API 统一请求层(规范 §131) * * 职责:纯 HTTP 客户端,统一处理 Token / 超时 / 错误码 / Session 失效。 * 不含任何业务域方法(域方法在 services/ 层,规范 §130 页面不直接调用底层 request)。 */ const { BUSINESS_LIMITS, REQUEST_ERROR } = require('../constants'); const { API_BASE_URL } = require('../env'); /** * 未成功操作的幂等键保留时长。 * * 取 10 分钟:足够覆盖用户「看到失败 → 重试」的常规间隔,又不至于让一把 * 早该作废的键长期占据缓存、把两次真正独立的操作误认成同一次。 */ const IDEMPOTENCY_KEY_TTL_MS = 10 * 60 * 1000; class ApiClient { constructor() { this.baseURL = API_BASE_URL; this.token = null; this._refreshToken = null; /** @type {((reason: any) => void)|null} Session 失效回调,由 AuthStore 注入 */ this.onSessionExpired = null; /** @type {((config: object) => void)|null} 维护态拦截回调,由 AppStore 注入 */ this.onMaintenance = null; /** 未确认成功的幂等键缓存:操作指纹 -> {key, createdAt}。语义见 idempotencyKey()。 */ this._pendingIdempotency = {}; } /** * 取一次逻辑操作的幂等键。 * * @param {string} scope 操作域,如 'activity-register' * @param {string} [subject] 操作对象(活动 ID / 目标用户 ID 等) * @returns {string} 幂等键 * * 幂等键的语义是「同一次逻辑操作的重复投递用同一把键」,而不是「每次调用 * 一把新键」。此前各 service 自行拼 `xxx:${Date.now()}`,每次调用都是新键 * —— 后端即便实现了幂等也永远命中不了,「弱网重发」照样产生重复数据。 * * 这里由 api 层统一托管:同一次**尚未成功**的操作复用同一把键, * 成功后才释放,下一次点击才是新的一次操作。 */ idempotencyKey(scope, subject) { const fingerprint = `${scope}::${subject == null ? '' : subject}`; const now = Date.now(); const cached = this._pendingIdempotency[fingerprint]; if (cached && now - cached.createdAt < IDEMPOTENCY_KEY_TTL_MS) { return cached.key; } const key = `${scope}:${subject == null ? '' : subject}:${this.generateRequestId()}`; this._pendingIdempotency[fingerprint] = { key, createdAt: now }; return key; } /** * 释放幂等键:一次逻辑操作成功后调用,下一次同域操作属新的一次。 * * 公开方法:`request()` 内部在业务成功后自动调用;直接使用 * `utils/requestPromise` 绕开本层封装的页面(如 counseling-request) * 需在成功后自行调用,否则一把旧键会被一直复用。 */ releaseIdempotencyKey(key) { if (!key) return; const fingerprints = Object.keys(this._pendingIdempotency); for (let i = 0; i < fingerprints.length; i += 1) { if (this._pendingIdempotency[fingerprints[i]].key === key) { delete this._pendingIdempotency[fingerprints[i]]; return; } } } /** 读取本地存储的 access token */ getToken() { if (this.token) return this.token; try { this.token = wx.getStorageSync('accessToken') || null; this._refreshToken = wx.getStorageSync('refreshToken') || null; } catch (e) { // ignore } return this.token; } /** 读取本地存储的 refresh token(供 auth service 刷新使用) */ getRefreshToken() { if (this._refreshToken) return this._refreshToken; try { this._refreshToken = wx.getStorageSync('refreshToken') || null; } catch (e) { // ignore } return this._refreshToken; } /** 持久化 token(登录/刷新成功后调用) */ saveToken(token, refreshToken) { this.token = token; this._refreshToken = refreshToken; wx.setStorageSync('accessToken', token); wx.setStorageSync('refreshToken', refreshToken); } /** 清除 token(登出/Session 失效后调用) */ clearToken() { this.token = null; this._refreshToken = null; wx.removeStorageSync('accessToken'); wx.removeStorageSync('refreshToken'); } /** 生成请求 ID(用于链路追踪) */ generateRequestId() { return Date.now().toString(36) + Math.random().toString(36).substring(2, 8); } /** * 通用请求方法 * @param {{url: string, method?: string, data?: any, headers?: object, idempotencyKey?: string, timeout?: number}} options */ request(options) { const { url, method = 'GET', data, headers = {}, idempotencyKey, timeout } = options; const token = this.getToken(); const requestId = this.generateRequestId(); return new Promise((resolve, reject) => { wx.request({ url: this.baseURL + url, method, data, timeout: timeout || BUSINESS_LIMITS.REQUEST_TIMEOUT_MS, header: { 'Content-Type': 'application/json', 'X-Request-Id': requestId, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers, }, success: (res) => { // 维护态拦截(规范 §77) if (res.statusCode === 503) { if (typeof this.onMaintenance === 'function') this.onMaintenance(res.data || {}); reject({ code: REQUEST_ERROR.MAINTENANCE, message: '平台正在维护,请稍后再试', requestId, }); return; } if (res.statusCode === 200) { const body = res.data || {}; if (body.success) { /** * 顺序很关键:**先 resolve,再做收尾动作**。 * 收尾逻辑(释放幂等键)绝不能挡在响应路径上 —— 2026-09-19 实测过代价: * 此处曾把方法名误写成 `_releaseIdempotencyKey`(多一个下划线),抛错发生在 * `resolve()` **之前**,于是**每一个成功请求**的 Promise 都永不 settle, * 表现为「所有页面永远停在 loading + App.onError 刷屏」。 * 一个字符的笔误被放大成全局不可用,就是因为收尾动作写在了响应路径上。 * * 语义(勿改):操作已成功落地 → 释放幂等键,下一次同域操作是新的一次; * 失败时刻意**不释放** —— 用户重试时复用同一把键,后端才能识别出这是重复投递 * 而不是第二次操作。 */ resolve(body.data); try { this.releaseIdempotencyKey(idempotencyKey); } catch (error) { // 释放失败最多是"下次操作没拿到新键",绝不能升级为业务失败。 console.warn('[api] 释放幂等键失败,已忽略', error); } } else { reject({ code: body.code || REQUEST_ERROR.BUSINESS_ERROR, message: body.message, requestId, }); } } else if (res.statusCode === 401) { // 服务端 message 优先:token 失效的原因可能各不相同(过期/被踢/未登录)。 const body = res.data || {}; this.clearToken(); if (typeof this.onSessionExpired === 'function') this.onSessionExpired(); reject({ code: REQUEST_ERROR.UNAUTHORIZED, message: body.message || '请重新登录', requestId }); } else if (res.statusCode === 403) { /** * 403 现在承载的不只是"无权限",还有账号被封禁、资料未过审、 * 未双向匹配等状态类拒绝。code 保留服务端业务码(如 USER_BANNED), * message 保留服务端原文 —— 否则页面只会看到一句"无权限访问", * 用户不知道自己为什么被拦、该怎么处理。 */ const body = res.data || {}; reject({ code: body.code || REQUEST_ERROR.FORBIDDEN, message: body.message || '无权限访问', requestId, }); } else if (res.statusCode === 429) { // 既可能是限流,也可能是 PASS_COOLDOWN 这类冷却,原文更有信息量。 const body = res.data || {}; reject({ code: body.code || REQUEST_ERROR.RATE_LIMITED, message: body.message || '请求过于频繁,请稍后重试', requestId, }); } else if (res.statusCode === 409) { // 与当前状态冲突(重复报名、名额已满、已喜欢过):不该重试,先查状态。 const body = res.data || {}; reject({ code: body.code || REQUEST_ERROR.BUSINESS_ERROR, message: body.message || '当前状态无法执行此操作', requestId, }); } else { reject({ code: REQUEST_ERROR.SERVER_ERROR, message: '服务暂时不可用', requestId }); } }, fail: (err) => { reject({ code: REQUEST_ERROR.NETWORK_ERROR, message: '网络连接失败,请检查网络后重试', requestId, error: err, }); }, }); }); } get(url, data, headers) { return this.request({ url, method: 'GET', data, headers }); } post(url, data, headers, idempotencyKey) { return this.request({ url, method: 'POST', data, headers, idempotencyKey }); } put(url, data, headers, idempotencyKey) { return this.request({ url, method: 'PUT', data, headers, idempotencyKey }); } patch(url, data, headers, idempotencyKey) { return this.request({ url, method: 'PATCH', data, headers, idempotencyKey }); } delete(url, data, headers, idempotencyKey) { return this.request({ url, method: 'DELETE', data, headers, idempotencyKey }); } } const apiClient = new ApiClient(); module.exports = apiClient;