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

194 lines
6.8 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<{ 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;
}
}
async loginWithDemo(code, displayName, phone) {
this.setState({ authStatus: 'logging_in', lastError: null });
try {
const result = displayName
? await authService.demoRegister({ code, displayName, phone })
: await authService.demoLogin({ code });
api.saveToken(result.accessToken, result.refreshToken);
const snapshot = { isLoggedIn: true, userId: result.userId, phoneBound: !result.isNewUser };
this.setState({ ...snapshot, authStatus: 'idle' });
this.persist(snapshot, STORAGE_KEYS.IS_LOGGED_IN);
return result;
} 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 {
// 后端返回 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;