/** * 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<{ isNewUser: boolean }>} isNewUser = 后端判定「尚未绑定手机号」 */ 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, /** * 必须读后端真实字段 isNewUser。 * 此前读的是 `!result.needPhone` —— 后端从不返回 needPhone,恒为 undefined, * 于是 `!undefined === true`:**每个用户一登录就被标记成已绑手机号**, * 「我的」页不再引导绑号。这是与 accessToken/token 同族的字段名不一致缺陷, * 也是本项目第三次踩到「跨端字段名对不上」(见 types/index.ts 的 UserLoginResult)。 */ phoneBound: !result.isNewUser, authStatus: 'idle', }; this.setState(patch); this.persist( { isLoggedIn: true, userId: patch.userId, phoneBound: patch.phoneBound }, STORAGE_KEYS.IS_LOGGED_IN, ); // 只回传后端确实提供的字段:needProfile 后端没有对应字段,凭空造 false 会误导调用方。 return { isNewUser: !!result.isNewUser }; } 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} 是否刷新成功 */ async refresh() { if (this._refreshPromise) return this._refreshPromise; this._refreshPromise = (async () => { this.setState({ authStatus: 'refreshing' }); try { // 后端返回 accessToken(非 token)。userId 已非必需(后端从 refreshToken 的 // subject 自证身份),仍显式带上:后端会校验它与令牌主体一致,能提前拦下 // 「拿 A 的令牌刷 B 的会话」这类串号。 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;