2026-09-09 22:05:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
2026-09-10 22:57:52 +08:00
|
|
|
|
if (!api.getToken()) {
|
|
|
|
|
|
try { wx.removeStorageSync(STORAGE_KEYS.IS_LOGGED_IN); } catch (e) {}
|
|
|
|
|
|
this.setState({ isLoggedIn: false, userId: null, phoneBound: false });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-09 22:05:00 +08:00
|
|
|
|
if (snapshot && typeof snapshot === 'object') {
|
|
|
|
|
|
this.setState({
|
|
|
|
|
|
isLoggedIn: !!snapshot.isLoggedIn,
|
|
|
|
|
|
userId: snapshot.userId || null,
|
|
|
|
|
|
phoneBound: !!snapshot.phoneBound,
|
|
|
|
|
|
});
|
2026-09-10 22:57:52 +08:00
|
|
|
|
} else {
|
2026-09-09 22:05:00 +08:00
|
|
|
|
// 兼容旧版仅存了 token 的场景
|
|
|
|
|
|
this.setState({ isLoggedIn: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 微信登录
|
|
|
|
|
|
* @param {string} code wx.login() 拿到的 code
|
2026-09-22 21:41:30 +08:00
|
|
|
|
* @returns {Promise<{ isNewUser: boolean }>} isNewUser = 后端判定「尚未绑定手机号」
|
2026-09-09 22:05:00 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async loginWithWechat(code) {
|
|
|
|
|
|
this.setState({ authStatus: 'logging_in', lastError: null });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await authService.wechatLogin({ code });
|
2026-09-22 19:08:16 +08:00
|
|
|
|
// 注意:后端登录返回字段为 accessToken(非 token),必须用 accessToken 才能持久化成功。
|
|
|
|
|
|
api.saveToken(result.accessToken, result.refreshToken);
|
2026-09-09 22:05:00 +08:00
|
|
|
|
const patch = {
|
|
|
|
|
|
isLoggedIn: true,
|
|
|
|
|
|
userId: result.userId || null,
|
2026-09-22 21:41:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 必须读后端真实字段 isNewUser。
|
|
|
|
|
|
* 此前读的是 `!result.needPhone` —— 后端从不返回 needPhone,恒为 undefined,
|
|
|
|
|
|
* 于是 `!undefined === true`:**每个用户一登录就被标记成已绑手机号**,
|
|
|
|
|
|
* 「我的」页不再引导绑号。这是与 accessToken/token 同族的字段名不一致缺陷,
|
|
|
|
|
|
* 也是本项目第三次踩到「跨端字段名对不上」(见 types/index.ts 的 UserLoginResult)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
phoneBound: !result.isNewUser,
|
2026-09-09 22:05:00 +08:00
|
|
|
|
authStatus: 'idle',
|
|
|
|
|
|
};
|
|
|
|
|
|
this.setState(patch);
|
|
|
|
|
|
this.persist(
|
|
|
|
|
|
{ isLoggedIn: true, userId: patch.userId, phoneBound: patch.phoneBound },
|
|
|
|
|
|
STORAGE_KEYS.IS_LOGGED_IN,
|
|
|
|
|
|
);
|
2026-09-22 21:41:30 +08:00
|
|
|
|
// 只回传后端确实提供的字段:needProfile 后端没有对应字段,凭空造 false 会误导调用方。
|
|
|
|
|
|
return { isNewUser: !!result.isNewUser };
|
2026-09-09 22:05:00 +08:00
|
|
|
|
} 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 {
|
2026-09-22 21:41:30 +08:00
|
|
|
|
// 后端返回 accessToken(非 token)。userId 已非必需(后端从 refreshToken 的
|
|
|
|
|
|
// subject 自证身份),仍显式带上:后端会校验它与令牌主体一致,能提前拦下
|
|
|
|
|
|
// 「拿 A 的令牌刷 B 的会话」这类串号。
|
2026-09-22 19:08:16 +08:00
|
|
|
|
const result = await authService.refresh(this.state.userId);
|
|
|
|
|
|
api.saveToken(result.accessToken, result.refreshToken);
|
2026-09-09 22:05:00 +08:00
|
|
|
|
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;
|