Files
danyang-match/miniapp/utils/api.js
T

241 lines
9.1 KiB
JavaScript
Raw Normal View History

2026-09-09 22:05:00 +08:00
/**
* 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;
2026-09-09 22:05:00 +08:00
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;
}
}
2026-09-09 22:05:00 +08:00
}
/** 读取本地存储的 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) {
// 操作已成功落地:释放幂等键,下一次同域操作是新的一次。
// 失败时刻意**不释放** —— 用户重试时复用同一把键,
// 后端才能识别出这是重复投递而不是第二次操作。
this._releaseIdempotencyKey(idempotencyKey);
2026-09-09 22:05:00 +08:00
resolve(body.data);
} else {
reject({
code: body.code || REQUEST_ERROR.BUSINESS_ERROR,
message: body.message,
requestId,
});
}
} else if (res.statusCode === 401) {
// 服务端 message 优先:token 失效的原因可能各不相同(过期/被踢/未登录)。
const body = res.data || {};
2026-09-09 22:05:00 +08:00
this.clearToken();
if (typeof this.onSessionExpired === 'function') this.onSessionExpired();
reject({ code: REQUEST_ERROR.UNAUTHORIZED, message: body.message || '请重新登录', requestId });
2026-09-09 22:05:00 +08:00
} 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,
});
2026-09-09 22:05:00 +08:00
} 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,
});
2026-09-09 22:05:00 +08:00
} 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;