/** * 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; * */ 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; /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */ expiresAt: Ref; /** 当前用户真实姓名(未登录为 '';① 验账密成功后即有值) */ realName: Ref; /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */ roles: Ref; /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */ permissions: Ref>; /** 可见导航菜单树(登录② 响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */ menuTree: Ref; /** 两步登录① 暂存:一次性票据(null = 未处于 ①→② 过渡态) */ pendingTicket: Ref; /** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */ pendingMemberships: Ref; /** 响应式视图是否已从后端恢复(② 签发或 /auth/me 成功);刷新后为 false,守卫据此触发 restoreMe 一次 */ restored: Ref; /** 是否已登录(有 access token 且未过期;守卫链第一步) */ isLoggedIn: ComputedRef; /** 判断是否持有指定权限码(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; /** 记住上次作用域(applyLogin 时调用;写 localStorage,下次登录作用域选择页默认选中) */ rememberScope: (institutionId: number, campusId: number) => void; /** 读取上次作用域(作用域选择页初始化默认选中;无记录返回 null) */ lastScope: () => { institutionId: number; campusId: number } | null; /** 执行登出(调后端吊销 → 清本地态) */ doLogout: () => Promise; /** 从 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 | undefined; export const useLogin = defineStore('login', (): LoginStore => { const accessToken = ref(null); const expiresAt = ref(null); const realName = ref(''); const roles = ref([]); const permissions = ref(new Set()); // 可见导航菜单树(仅内存态,不落 localStorage——menuTree 是响应式视图非凭证, // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项)) const menuTree = ref([]); // 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明) const pendingTicket = ref(null); const pendingMemberships = ref([]); // 视图恢复标记: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:true = 恢复成功(roles/permissions/menuTree/scope 已同步); * false = 无 token / 恢复失败(视图保持现状:菜单全隐、scope 未就绪)。 * 异常场景: * - code=3001(token 有效但用户被删,罕见):按登出处理(强制重新登录)并返回 false; * - access 过期:http 拦截器自动 refresh 重放,仍失败返回 false; * - 网络层故障:返回 false(调用方不阻塞——守卫继续走作用域选择页降级)。 * * 并发纪律:同一时刻仅一个 in-flight 请求(守卫并发导航复用同一 Promise)。 */ async function restoreMe(): Promise { 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:恒 resolve(失败场景静默,网络错误不阻塞登出)。 */ async function doLogout(): Promise { 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, }; });