用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:
1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。
第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。
同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。
后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则
守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归
验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
注入前后工作区 SHA256 逐字节一致(零污染)
291 lines
12 KiB
JavaScript
291 lines
12 KiB
JavaScript
/**
|
||
* 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;
|
||
// 冷启动时同步给直接读 app.globalData.token 的旧页面
|
||
const app = typeof getApp === 'function' ? getApp() : null;
|
||
if (app && app.globalData) app.globalData.token = this.token;
|
||
} 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);
|
||
// 同步给直接读 app.globalData.token 的旧页面(否则它们发 Bearer undefined → 401)
|
||
const app = typeof getApp === 'function' ? getApp() : null;
|
||
if (app && app.globalData) app.globalData.token = token;
|
||
}
|
||
|
||
/** 清除 token(登出/Session 失效后调用) */
|
||
clearToken() {
|
||
this.token = null;
|
||
this._refreshToken = null;
|
||
wx.removeStorageSync('accessToken');
|
||
wx.removeStorageSync('refreshToken');
|
||
const app = typeof getApp === 'function' ? getApp() : null;
|
||
if (app && app.globalData) app.globalData.token = null;
|
||
}
|
||
|
||
/** 生成请求 ID(用于链路追踪) */
|
||
generateRequestId() {
|
||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||
}
|
||
|
||
/**
|
||
* 是否为认证端点(/auth/*)。
|
||
*
|
||
* 认证端点自身返回 401 时**不得**触发 Session 失效流程。原因:401 分支会调用
|
||
* `onSessionExpired`,而它(AuthStore.handleSessionExpired)会去调 `/auth/refresh`
|
||
* —— 若 `/auth/refresh` 自己也回 401,就形成「刷新 → 失效 → 再刷新」的自我递归。
|
||
*
|
||
* 这条路径在 2026-09-22 之前是隐性的:后端当时把「refresh token 无效」抛成
|
||
* 500,401 分支根本走不到。后端把语义修正为 401(正确)之后,这层守卫
|
||
* 就成了必需 —— 否则修好一个缺陷会引入另一个。
|
||
*
|
||
* 认证端点的失败由调用方(AuthStore)判定,本层只如实上报。
|
||
*/
|
||
isAuthEndpoint(url) {
|
||
return typeof url === 'string' && url.indexOf('/auth/') === 0;
|
||
}
|
||
|
||
/**
|
||
* 通用请求方法
|
||
* @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 || {};
|
||
if (this.isAuthEndpoint(url)) {
|
||
// /auth/refresh 等端点自己回 401 = 「刷新凭据已失效」。
|
||
// 此处若照常 clearToken + onSessionExpired,就会再次发起 /auth/refresh(自我递归)。
|
||
// 状态与清理交给调用方:AuthStore.refresh 失败时会调用 _handleExpired()。
|
||
reject({
|
||
code: body.code || REQUEST_ERROR.UNAUTHORIZED,
|
||
message: body.message || '请重新登录',
|
||
requestId,
|
||
});
|
||
return;
|
||
}
|
||
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;
|