| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- /**
- * login.ts —— 登录态 store(Pinia setup 风格;README §3.2 状态最小化)。
- *
- * 职责边界:
- * - 持有**响应式视图**(token/角色/权限码/菜单树),供组件 `useLogin()` 读取渲染菜单/按钮;
- * - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
- * - 登录/登出动作封装在此(调 api/auth/login.ts 两步 → 写 token-store → 同步视图 + scope)。
- *
- * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
- * ① 验账密成功 → applyStep1():暂存一次性票据 + 作用域列表 + 用户名(**不落 token**);
- * 单条作用域:登录页自动 applyLogin(②);多条:进作用域选择页由用户挑选。
- * ② 选定作用域签发 → applyLogin(TokenPair):写 token + 同步 roles/permissions/menuTree + 设 scope。
- * pendingTicket/pendingMemberships 为 ①→② 之间的**内存过渡态**(票据 5min 有效、单次消费;
- * 不落 localStorage——泄露的 ticket 可被换 token,落盘徒增风险面;刷新即丢,需重走①)。
- *
- * 典型使用(组件内):
- *
- * const login = useLogin();
- * v-perm="'academic.fee.refund'" 指令读 login.hasPerm;
- * <template>{{ login.realName }}</template>
- */
- import { logout as apiLogout, type LoginStep1, type MembershipView, type TokenPair } from '@/api/auth/login';
- import { fetchMe } from '@/api/auth/me';
- import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
- import { useScope } from '@/store/scope';
- import type { ComputedRef, Ref } from 'vue';
- import type { MenuNode } from '@/types/api';
- /**
- * setup store 的返回面类型(defineStore 的 setup 函数返回值;字段为 ref 本体)。
- * 注:经 `useLogin()` 消费时 Pinia **自动解包**(组件里 login.accessToken 是 string | null),
- * 本接口仅约束 setup 函数返回,消费方以 useLogin() 推断类型为准。
- */
- export interface LoginStore {
- /** access token(响应式视图;物理值以 token-store 为准) */
- accessToken: Ref<string | null>;
- /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
- expiresAt: Ref<number | null>;
- /** 当前用户真实姓名(未登录为 '';① 验账密成功后即有值) */
- realName: Ref<string>;
- /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
- roles: Ref<string[]>;
- /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */
- permissions: Ref<Set<string>>;
- /** 可见导航菜单树(登录② 响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
- menuTree: Ref<MenuNode[]>;
- /** 两步登录① 暂存:一次性票据(null = 未处于 ①→② 过渡态) */
- pendingTicket: Ref<string | null>;
- /** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
- pendingMemberships: Ref<MembershipView[]>;
- /** 响应式视图是否已从后端恢复(② 签发或 /auth/me 成功);刷新后为 false,守卫据此触发 restoreMe 一次 */
- restored: Ref<boolean>;
- /** 是否已登录(有 access token 且未过期;守卫链第一步) */
- isLoggedIn: ComputedRef<boolean>;
- /** 判断是否持有指定权限码(v-perm 指令用) */
- hasPerm: (perm: string) => boolean;
- /** 登录① 成功后的过渡态落盘(暂存票据 + 作用域列表 + 用户名;不写 token) */
- applyStep1: (data: LoginStep1) => void;
- /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
- applyLogin: (data: TokenPair) => void;
- /**
- * 凭有效 access 恢复响应式视图(GET /auth/me;菜单刷新恢复用,T6.5+)。
- *
- * 语义:页面刷新后 token 三件套仍在 token-store,但 roles/permissions/menuTree 是 ② 下发的内存态
- *(不落盘)——调 /auth/me 按已验签 claims 回读用户 + 当前作用域 + 按角色重新解析菜单/权限。
- * - 成功:同步 realName/roles/permissions/menuTree + 设 scope(institutionId>0 时);
- * - 失败(网络/3001 用户被删/5002):返回 false,视图保持现状(菜单全隐、scope 未就绪)——
- * 3001(用户被删)调用方应据返回的 code 决定登出;本方法只报恢复成败,不代跳。
- * - access 过期:http 拦截器自动 refresh 重放,恢复失败才返回 false。
- */
- restoreMe: () => Promise<boolean>;
- /** 记住上次作用域(applyLogin 时调用;写 localStorage,下次登录作用域选择页默认选中) */
- rememberScope: (institutionId: number, campusId: number) => void;
- /** 读取上次作用域(作用域选择页初始化默认选中;无记录返回 null) */
- lastScope: () => { institutionId: number; campusId: number } | null;
- /** 执行登出(调后端吊销 → 清本地态) */
- doLogout: () => Promise<void>;
- /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
- hydrate: () => void;
- }
- /**
- * 作用域偏好存储键(记住"上次进入的机构/校区",下次登录的作用域选择页默认选中该项)。
- * 非凭证(不涉 token/票据),可安全持久化到 localStorage;doLogout 不清除(偏好跨登录保留)。
- */
- export const LAST_SCOPE_KEY = 'ysw:last-scope';
- // /auth/me 恢复的 in-flight 单例(并发导航只发一次;模块级,store 实例唯一,语义等价)。
- let restoreInflight: Promise<boolean> | undefined;
- export const useLogin = defineStore('login', (): LoginStore => {
- const accessToken = ref<string | null>(null);
- const expiresAt = ref<number | null>(null);
- const realName = ref('');
- const roles = ref<string[]>([]);
- const permissions = ref(new Set<string>());
- // 可见导航菜单树(仅内存态,不落 localStorage——menuTree 是响应式视图非凭证,
- // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
- const menuTree = ref<MenuNode[]>([]);
- // 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
- const pendingTicket = ref<string | null>(null);
- const pendingMemberships = ref<MembershipView[]>([]);
- // 视图恢复标记:applyLogin(② 签发)或 restoreMe(/auth/me)成功后为 true;
- // hydrate(刷新后 token 在但视图空)/ doLogout 后置 false——守卫据此触发一次恢复。
- const restored = ref(false);
- /** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
- const isLoggedIn = computed(() => {
- if (!accessToken.value) return false;
- const exp = expiresAt.value;
- return exp === null || Date.now() < exp - 30_000;
- });
- /**
- * 判断是否持有指定权限码。
- *
- * 入参:
- * - perm string:`域.资源.动作` 三段式权限码(如 `academic.fee.refund`)。
- *
- * 出参:
- * - boolean:未登录恒 false(fail-closed);登录态按 permissions 集合精确匹配。
- */
- function hasPerm(perm: string): boolean {
- return isLoggedIn.value && permissions.value.has(perm);
- }
- /**
- * 登录① 成功过渡态落盘(暂存票据 + 作用域列表 + 用户名;**不写 token**)。
- *
- * 入参:
- * - data LoginStep1:api/auth/login.ts ① 返回的 data(code=200 时)。
- *
- * 出参:无。
- * 异常场景:
- * - data 缺 loginTicket:视为后端契约违约,过渡态不落盘(pendingTicket 保持 null)。
- */
- function applyStep1(data: LoginStep1): void {
- if (!data.loginTicket) return;
- pendingTicket.value = data.loginTicket;
- pendingMemberships.value = data.memberships ?? [];
- realName.value = data.user?.realName ?? '';
- }
- /**
- * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步 + scope 设置)。
- *
- * 入参:
- * - data TokenPair:api/auth/login.ts ② 返回的 data(code=200 时)。
- *
- * 出参:无。
- * 异常场景:
- * - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
- * - institutionId ≤ 0(平台级超管):scope.set 拒绝(机构必选),作用域保持未就绪——
- * 平台级角色的作用域语义为独立待办,本处不特殊处理(对齐现状)。
- */
- function applyLogin(data: TokenPair): void {
- if (!data.accessToken) return;
- const scope = useScope();
- setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
- accessToken.value = data.accessToken;
- expiresAt.value = data.expiresAt;
- realName.value = data.user?.realName ?? realName.value;
- roles.value = data.roles ?? [];
- permissions.value = new Set(data.permissions ?? []);
- menuTree.value = data.menuTree ?? [];
- // 选定作用域写入 scope store(守卫链第四步据此放行;institutionId>0 才合法)
- if (data.institutionId > 0) {
- scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
- }
- // ② 完成:清过渡态(票据已消费,留着反成泄露面);视图已完整 → 恢复标记置位
- pendingTicket.value = null;
- pendingMemberships.value = [];
- restored.value = true;
- // 记住本次作用域(机构>0 才记;campus 0 = 机构级也记,作为下次默认)
- if (data.institutionId > 0) rememberScope(data.institutionId, data.campusId ?? 0);
- }
- /**
- * 记住上次作用域(写 localStorage;下次登录的作用域选择页据此默认选中)。
- *
- * 入参:institutionId(>0 才记)/ campusId(0 = 机构级)。
- * 出参:无。
- * 异常场景:localStorage 不可用(隐私模式禁用):静默跳过,不影响登录流程。
- */
- function rememberScope(institutionId: number, campusId: number): void {
- if (!institutionId || institutionId <= 0) return;
- try {
- localStorage.setItem(LAST_SCOPE_KEY, JSON.stringify({ institutionId, campusId: campusId ?? 0 }));
- } catch {
- /* 隐私模式等 storage 禁用:忽略(偏好丢失不阻断业务) */
- }
- }
- /**
- * 读取上次作用域(作用域选择页初始化用)。
- *
- * 入参:无。
- * 出参:{ institutionId, campusId } | null(无记录 / 解析失败 = null,调用方回退首项)。
- * 异常场景:JSON 解析失败 / 结构不符:返回 null(fail-open 回退默认首项)。
- */
- function lastScope(): { institutionId: number; campusId: number } | null {
- try {
- const raw = localStorage.getItem(LAST_SCOPE_KEY);
- if (!raw) return null;
- const p = JSON.parse(raw) as { institutionId?: number; campusId?: number };
- if (typeof p.institutionId === 'number' && p.institutionId > 0) {
- return { institutionId: p.institutionId, campusId: typeof p.campusId === 'number' ? p.campusId : 0 };
- }
- return null;
- } catch {
- return null;
- }
- }
- /**
- * 凭有效 access 恢复响应式视图(GET /auth/me,菜单刷新恢复用)。
- *
- * 入参:无(作用域/角色来自已验签 JWT claims,后端按 claims 回读 + 重新解析菜单/权限)。
- *
- * 出参:
- * - Promise<boolean>:true = 恢复成功(roles/permissions/menuTree/scope 已同步);
- * false = 无 token / 恢复失败(视图保持现状:菜单全隐、scope 未就绪)。
- * 异常场景:
- * - code=3001(token 有效但用户被删,罕见):按登出处理(强制重新登录)并返回 false;
- * - access 过期:http 拦截器自动 refresh 重放,仍失败返回 false;
- * - 网络层故障:返回 false(调用方不阻塞——守卫继续走作用域选择页降级)。
- *
- * 并发纪律:同一时刻仅一个 in-flight 请求(守卫并发导航复用同一 Promise)。
- */
- async function restoreMe(): Promise<boolean> {
- if (!accessToken.value) return false;
- if (restoreInflight) return restoreInflight;
- restoreInflight = (async () => {
- try {
- const res = await fetchMe();
- if (res.code === 3001) {
- // token 有效但用户被删(罕见):会话作废,强制重新登录
- void doLogout();
- return false;
- }
- if (res.code !== 200 || !res.data) return false;
- const d = res.data;
- realName.value = d.realName || realName.value;
- roles.value = d.roles ?? [];
- permissions.value = new Set(d.permissions ?? []);
- menuTree.value = d.menuTree ?? [];
- if (d.institutionId > 0) {
- useScope().set({ institutionId: d.institutionId, campusId: d.campusId ?? 0 });
- }
- restored.value = true;
- return true;
- } catch {
- return false;
- } finally {
- restoreInflight = undefined;
- }
- })();
- return restoreInflight;
- }
- /**
- * 执行登出:调后端吊销 → 清本地态(无论后端是否成功,本地态必清——登出不允许卡住)。
- *
- * 入参:无。
- *
- * 出参:
- * - Promise<void>:恒 resolve(失败场景静默,网络错误不阻塞登出)。
- */
- async function doLogout(): Promise<void> {
- const refresh = getRefreshToken();
- try {
- if (refresh) await apiLogout(refresh);
- } catch {
- // 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效)
- }
- const scope = useScope();
- scope.reset();
- clearTokens();
- accessToken.value = null;
- expiresAt.value = null;
- realName.value = '';
- roles.value = [];
- permissions.value = new Set();
- menuTree.value = [];
- pendingTicket.value = null;
- pendingMemberships.value = [];
- restored.value = false;
- }
- /**
- * 从 token-store 同步本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。
- *
- * 入参:无。
- *
- * 出参:无。
- * 异常场景:
- * - 无 token(未登录):视图置空,守卫链引导到登录页。
- * - 有 token(刷新后):roles/permissions/menuTree 是 ② 下发的内存态(刷新即丢),
- * 此处仅恢复 token 三件套;完整视图恢复由守卫调 restoreMe()(/auth/me)完成。
- */
- function hydrate(): void {
- accessToken.value = getAccessToken();
- expiresAt.value = getExpiresAt();
- pendingTicket.value = null;
- pendingMemberships.value = [];
- // 刷新后视图未恢复(roles/menuTree/scope 全空):置 false,守卫首次导航触发 restoreMe
- restored.value = false;
- if (!accessToken.value) {
- realName.value = '';
- roles.value = [];
- permissions.value = new Set();
- menuTree.value = [];
- }
- }
- return {
- accessToken,
- expiresAt,
- realName,
- roles,
- permissions,
- menuTree,
- pendingTicket,
- pendingMemberships,
- restored,
- isLoggedIn,
- hasPerm,
- applyStep1,
- applyLogin,
- rememberScope,
- lastScope,
- restoreMe,
- doLogout,
- hydrate,
- };
- });
|