Files
danyang-match/miniapp/stores/authStore.js
T

160 lines
4.9 KiB
JavaScript
Raw Normal View History

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);
if (snapshot && typeof snapshot === 'object') {
this.setState({
isLoggedIn: !!snapshot.isLoggedIn,
userId: snapshot.userId || null,
phoneBound: !!snapshot.phoneBound,
});
} else if (api.getToken()) {
// 兼容旧版仅存了 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 });
api.saveToken(result.token, 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 {
const result = await authService.refresh();
api.saveToken(result.token, 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;