Files
danyang-match/miniapp/stores/authStore.js
T
WorkBuddy 63c18d89c2 fix(miniapp): 修复全站 401 —— 认证链字段名不一致 + 首页 onRetry 缺失
根因(读码 + 本机后端实测坐实):
- 后端登录/刷新返回 accessToken,前端 authStore 却读 result.token →
  accessToken 从未落库 → 每个请求都不带 Authorization → 全站 401(首页无法加载)。
- /auth/refresh 少传 userId:后端以 userId 定位 Redis refresh token,
  缺失即 BAD_REQUEST → HTTP 400(实测 {refreshToken} 体 → 400)。
- 首页 index.wxml 绑定 bind:retry="onRetry"(ui-error-state 触发 retry 事件),
  但 index.ts 无该方法 → 控制台报错、错误页「重新加载」失效。
- app.globalData.token 从未赋值,多页直读它拼 Authorization → Bearer undefined。

修复:
- authStore:登录/刷新改用 result.accessToken;刷新传 this.state.userId。
- authService.refresh(userId):发送 { userId, refreshToken }。
- pages/index:新增 onRetry;onLoad 订阅 authStore(expired → 回登录引导;
  刷新成功且停在错误态 → 自动重载);onUnload 退订。
- utils/api:saveToken/clearToken/getToken 同步 app.globalData.token,兼容旧页面。

守卫补强:
- ui-tests/miniapp-auth-contracts.mjs 原仅覆盖 hydrate,对字段名缺陷零覆盖。
  新增行为断言:登录后 storage.accessToken 必须等于后端 accessToken;
  刷新请求体必须含 userId + refreshToken 并持久化新 accessToken。
  已双向负向注入验证判别力(改回 result.token / 去掉 userId 均 exit 1)。

验证:
- tsc --noEmit -p miniapp/tsconfig.json → exit 0,零错误。
- scripts/run-ui-contracts.mjs → 18/18 PASS。
- 本机 8085 /auth/refresh 三态实测:{} → 400;{refreshToken} → 400;
  {userId,refreshToken} → 500(进入服务校验,非 400)。
- 未验证:开发者工具内实际登录→首页链路(本机无法驱动 devtools,待用户复测)。
2026-09-22 19:08:16 +08:00

167 lines
5.3 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.
/**
* AuthStore(规范 §131)
*
* 职责:
* - 微信登录 / 绑定手机号 / 刷新 Token / 登出
* - Token 持久化委托 utils/api.js 完成;本层只维护 isLoggedIn 等状态
* - 401 Session 失效时单飞刷新,刷新失败再触发 onSessionExpired
* - 注入 onSessionExpired 回调到 apiClient
*
* 不变量(AGENTS.md 第 3 条):未登录用户不可访问任何需要权限的页面,
* 路由守卫根据 AuthStore.isLoggedIn 决定是否重定向到登录页。
*/
const BaseStore = require('./baseStore');
const api = require('../utils/api');
const { authService } = require('../services');
const { STORAGE_KEYS, REQUEST_ERROR } = require('../constants');
class AuthStore extends BaseStore {
constructor() {
super({
isLoggedIn: false,
/** @type {string|null} */
userId: null,
/** @type {string|null} */
phoneBound: false,
/** @type {'idle'|'logging_in'|'refreshing'|'logging_out'|'expired'} */
authStatus: 'idle',
lastError: null,
});
this._refreshPromise = null;
// 注入 Session 失效回调(apiClient 在 401 时调用)
api.onSessionExpired = () => this.handleSessionExpired();
}
/** 冷启动从 storage 还原登录态 */
hydrate() {
const snapshot = BaseStore.hydrate(STORAGE_KEYS.IS_LOGGED_IN);
if (!api.getToken()) {
try { wx.removeStorageSync(STORAGE_KEYS.IS_LOGGED_IN); } catch (e) {}
this.setState({ isLoggedIn: false, userId: null, phoneBound: false });
return;
}
if (snapshot && typeof snapshot === 'object') {
this.setState({
isLoggedIn: !!snapshot.isLoggedIn,
userId: snapshot.userId || null,
phoneBound: !!snapshot.phoneBound,
});
} else {
// 兼容旧版仅存了 token 的场景
this.setState({ isLoggedIn: true });
}
}
/**
* 微信登录
* @param {string} code wx.login() 拿到的 code
* @returns {Promise<{ needPhone: boolean, needProfile: boolean }>}
*/
async loginWithWechat(code) {
this.setState({ authStatus: 'logging_in', lastError: null });
try {
const result = await authService.wechatLogin({ code });
// 注意:后端登录返回字段为 accessToken(非 token),必须用 accessToken 才能持久化成功。
api.saveToken(result.accessToken, result.refreshToken);
const patch = {
isLoggedIn: true,
userId: result.userId || null,
phoneBound: !result.needPhone,
authStatus: 'idle',
};
this.setState(patch);
this.persist(
{ isLoggedIn: true, userId: patch.userId, phoneBound: patch.phoneBound },
STORAGE_KEYS.IS_LOGGED_IN,
);
return { needPhone: !!result.needPhone, needProfile: !!result.needProfile };
} catch (err) {
this.setState({ authStatus: 'idle', lastError: err });
throw err;
}
}
/**
* 绑定手机号
* @param {{ phoneCode?: string, encryptedData?: string, iv?: string }} payload
*/
async bindPhone(payload) {
const result = await authService.bindPhone(payload);
this.setState({ phoneBound: true });
// 同步持久化
const snap = BaseStore.hydrate(STORAGE_KEYS.IS_LOGGED_IN) || {};
this.persist({ ...snap, phoneBound: true }, STORAGE_KEYS.IS_LOGGED_IN);
return result;
}
/**
* 刷新 Token(单飞:并发 401 只触发一次刷新)
* @returns {Promise<boolean>} 是否刷新成功
*/
async refresh() {
if (this._refreshPromise) return this._refreshPromise;
this._refreshPromise = (async () => {
this.setState({ authStatus: 'refreshing' });
try {
// 后端 /auth/refresh 需要 userId;后端返回 accessToken(非 token)。
const result = await authService.refresh(this.state.userId);
api.saveToken(result.accessToken, result.refreshToken);
this.setState({ authStatus: 'idle', isLoggedIn: true });
return true;
} catch (err) {
// 刷新失败 → 真正的 Session 失效
this._handleExpired();
return false;
} finally {
this._refreshPromise = null;
}
})();
return this._refreshPromise;
}
/** 401 时由 apiClient 调用:先尝试刷新,刷新失败再走 onSessionExpired */
async handleSessionExpired() {
// 已在登录流程中则不打断
if (this.state.authStatus === 'logging_in') return;
const ok = await this.refresh();
if (!ok) {
this._handleExpired();
}
}
/** 真正的 Session 失效:清 token、清状态、由路由守卫跳登录 */
_handleExpired() {
api.clearToken();
try { wx.removeStorageSync(STORAGE_KEYS.IS_LOGGED_IN); } catch (e) {}
this.setState({
isLoggedIn: false,
userId: null,
phoneBound: false,
authStatus: 'expired',
lastError: { code: REQUEST_ERROR.UNAUTHORIZED, message: '登录状态已过期,请重新登录' },
});
}
/** 主动登出 */
async logout() {
this.setState({ authStatus: 'logging_out' });
try {
await authService.logout();
} catch (e) {
// 后端登出失败不阻塞本地登出
}
api.clearToken();
try { wx.removeStorageSync(STORAGE_KEYS.IS_LOGGED_IN); } catch (e) {}
this.setState({
isLoggedIn: false,
userId: null,
phoneBound: false,
authStatus: 'idle',
lastError: null,
});
}
}
const authStore = new AuthStore();
module.exports = authStore;