Files
danyang-match/miniapp/utils/api.js
T
WorkBuddy b67a56479e fix(miniapp): 修 P0 请求层崩溃(一个下划线致所有请求卡死)+ 组件 WXSS 违规选择器,再补 3 项门禁
═══ 缺陷 1(P0,运行期):utils/api.js 成功回调调用不存在的方法 ═══

控制台:
  TypeError: _this._releaseIdempotencyKey is not a function

根因:类中定义的是 `releaseIdempotencyKey()`,成功回调写成了
`this._releaseIdempotencyKey(...)`(**多一个下划线**)。

**为什么一个字符的笔误是 P0**:该调用位于 `wx.request` 的 success 回调内、
且在 `resolve()` **之前**。抛错 → `resolve` 永不执行 → 该 Promise **永不 settle**
→ 所有页面永远停在 loading,同时 App.onError 刷屏。
即「一次成功请求」本应推进流程,实际把前端整成不可用。

修复不只是改名,而是纠正结构:
- **先 `resolve(body.data)`,再执行收尾动作** —— 收尾逻辑不允许挡在响应路径上;
- 释放动作自带 try/catch + console.warn:释放失败最多是"下次操作没拿到新键",
  不得升级为业务失败。
- 语义未变(勿改):成功→释放(下次同域操作是新的一次);失败→刻意不释放
  (重试复用同一把键,后端才能识别重复投递)。

═══ 缺陷 2(编译期警告):组件 wxss 用了标签选择器 ═══

  Some selectors are not allowed in component wxss ... (components/recommend-card/index.wxss:1:1092)

实测违规 1 处(23 个组件样式文件全扫):`.compact-like image` —— 组件 wxss 只允许类选择器。
改为 `.compact-like__icon` 并**同步在 WXML 补上类名**
(只改 wxss 会让样式静默落空,是另一种"假成功")。

═══ 门禁:15 → 18(既有的全是源码内容断言,抓不到上述两类)═══

- 第15 `wxml-structure-contracts`(上一提交新增,本轮 MIN_GUARDS 基线顺带对齐)
- 第16 `miniapp-self-method-contracts`:扫描 `this._x(` / `self._x(` 等私有调用,
  要求同文件有定义(方法简写 / 类字段 / `this._x = 值` / prototype 四种形态)。
  **判据先自证假阳性**:首版正则不允许 `.` 前缀,把 `this._unsub = store.bind(this)`
  形式误报为未定义(3 个页面);修正并加反向自检样例固化了该坑。
- 第17 `wxss-component-selector-contracts`:组件 wxss 禁标签/ID/属性选择器。
  **作用域刻意只限 miniapp/components/**(page 的 wxss 允许标签选择器,套上去必有误报)。
- 第18 `miniapp-api-client-behavior`:**唯一的行为断言** —— 桩掉 `wx.request` 真跑 api.js,
  验证成功 resolve / 失败 retain 键 / 成功 release 键 / 收尾抛错不影响响应路径 4 组。
  含 1s settle 超时,直接编码"永不 settle"这一失败模式。
- 新增 `scripts/lib/wxss-selectors.mjs` + `scripts/check-wxss-component-selectors.mjs`(CLI)。
- `MIN_GUARDS` 15 → 18;执行器措辞改为「源码结构与关键行为断言」。

═══ 验证 ═══

- `node scripts/run-ui-contracts.mjs` → **PASS 18 / 18**,退出 0;
- 负向注入:未定义私有方法 / 标签+ID+属性选择器 / 多余闭合标签 → 均精准变红,
  且**合法样例(`this._x = 值`、纯类选择器、属性内含 `>`)不被误报**;
  行为守卫的负向自证见下一提交/记录;`UI_GUARDS_MIN=99` 拒绝报绿。
- 文档口径同步:AGENTS.md、STATUS.md(4 处当前口径 + 新增 1.1.0-rc.18 行)、ci.yml 注释。
- **未验证**:微信开发者工具真实编译与真机渲染(本机无该环境)。
2026-09-19 11:40:38 +08:00

255 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;