login.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. /**
  2. * login.ts —— 登录态 store(Pinia setup 风格;README §3.2 状态最小化)。
  3. *
  4. * 职责边界:
  5. * - 持有**响应式视图**(token/角色/权限码/菜单树),供组件 `useLogin()` 读取渲染菜单/按钮;
  6. * - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
  7. * - 登录/登出动作封装在此(调 api/auth/login.ts 两步 → 写 token-store → 同步视图 + scope)。
  8. *
  9. * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
  10. * ① 验账密成功 → applyStep1():暂存一次性票据 + 作用域列表 + 用户名(**不落 token**);
  11. * 单条作用域:登录页自动 applyLogin(②);多条:进作用域选择页由用户挑选。
  12. * ② 选定作用域签发 → applyLogin(TokenPair):写 token + 同步 roles/permissions/menuTree + 设 scope。
  13. * pendingTicket/pendingMemberships 为 ①→② 之间的**内存过渡态**(票据 5min 有效、单次消费;
  14. * 不落 localStorage——泄露的 ticket 可被换 token,落盘徒增风险面;刷新即丢,需重走①)。
  15. *
  16. * 典型使用(组件内):
  17. *
  18. * const login = useLogin();
  19. * v-perm="'academic.fee.refund'" 指令读 login.hasPerm;
  20. * <template>{{ login.realName }}</template>
  21. */
  22. import { logout as apiLogout, type LoginStep1, type MembershipView, type TokenPair } from '@/api/auth/login';
  23. import { fetchMe } from '@/api/auth/me';
  24. import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
  25. import { useScope } from '@/store/scope';
  26. import type { ComputedRef, Ref } from 'vue';
  27. import type { MenuNode } from '@/types/api';
  28. /**
  29. * setup store 的返回面类型(defineStore 的 setup 函数返回值;字段为 ref 本体)。
  30. * 注:经 `useLogin()` 消费时 Pinia **自动解包**(组件里 login.accessToken 是 string | null),
  31. * 本接口仅约束 setup 函数返回,消费方以 useLogin() 推断类型为准。
  32. */
  33. export interface LoginStore {
  34. /** access token(响应式视图;物理值以 token-store 为准) */
  35. accessToken: Ref<string | null>;
  36. /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
  37. expiresAt: Ref<number | null>;
  38. /** 当前用户真实姓名(未登录为 '';① 验账密成功后即有值) */
  39. realName: Ref<string>;
  40. /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
  41. roles: Ref<string[]>;
  42. /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */
  43. permissions: Ref<Set<string>>;
  44. /** 可见导航菜单树(登录② 响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
  45. menuTree: Ref<MenuNode[]>;
  46. /** 两步登录① 暂存:一次性票据(null = 未处于 ①→② 过渡态) */
  47. pendingTicket: Ref<string | null>;
  48. /** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
  49. pendingMemberships: Ref<MembershipView[]>;
  50. /** 响应式视图是否已从后端恢复(② 签发或 /auth/me 成功);刷新后为 false,守卫据此触发 restoreMe 一次 */
  51. restored: Ref<boolean>;
  52. /** 是否已登录(有 access token 且未过期;守卫链第一步) */
  53. isLoggedIn: ComputedRef<boolean>;
  54. /** 判断是否持有指定权限码(v-perm 指令用) */
  55. hasPerm: (perm: string) => boolean;
  56. /** 登录① 成功后的过渡态落盘(暂存票据 + 作用域列表 + 用户名;不写 token) */
  57. applyStep1: (data: LoginStep1) => void;
  58. /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
  59. applyLogin: (data: TokenPair) => void;
  60. /**
  61. * 凭有效 access 恢复响应式视图(GET /auth/me;菜单刷新恢复用,T6.5+)。
  62. *
  63. * 语义:页面刷新后 token 三件套仍在 token-store,但 roles/permissions/menuTree 是 ② 下发的内存态
  64. *(不落盘)——调 /auth/me 按已验签 claims 回读用户 + 当前作用域 + 按角色重新解析菜单/权限。
  65. * - 成功:同步 realName/roles/permissions/menuTree + 设 scope(institutionId>0 时);
  66. * - 失败(网络/3001 用户被删/5002):返回 false,视图保持现状(菜单全隐、scope 未就绪)——
  67. * 3001(用户被删)调用方应据返回的 code 决定登出;本方法只报恢复成败,不代跳。
  68. * - access 过期:http 拦截器自动 refresh 重放,恢复失败才返回 false。
  69. */
  70. restoreMe: () => Promise<boolean>;
  71. /** 记住上次作用域(applyLogin 时调用;写 localStorage,下次登录作用域选择页默认选中) */
  72. rememberScope: (institutionId: number, campusId: number) => void;
  73. /** 读取上次作用域(作用域选择页初始化默认选中;无记录返回 null) */
  74. lastScope: () => { institutionId: number; campusId: number } | null;
  75. /** 执行登出(调后端吊销 → 清本地态) */
  76. doLogout: () => Promise<void>;
  77. /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
  78. hydrate: () => void;
  79. }
  80. /**
  81. * 作用域偏好存储键(记住"上次进入的机构/校区",下次登录的作用域选择页默认选中该项)。
  82. * 非凭证(不涉 token/票据),可安全持久化到 localStorage;doLogout 不清除(偏好跨登录保留)。
  83. */
  84. export const LAST_SCOPE_KEY = 'ysw:last-scope';
  85. // /auth/me 恢复的 in-flight 单例(并发导航只发一次;模块级,store 实例唯一,语义等价)。
  86. let restoreInflight: Promise<boolean> | undefined;
  87. export const useLogin = defineStore('login', (): LoginStore => {
  88. const accessToken = ref<string | null>(null);
  89. const expiresAt = ref<number | null>(null);
  90. const realName = ref('');
  91. const roles = ref<string[]>([]);
  92. const permissions = ref(new Set<string>());
  93. // 可见导航菜单树(仅内存态,不落 localStorage——menuTree 是响应式视图非凭证,
  94. // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
  95. const menuTree = ref<MenuNode[]>([]);
  96. // 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
  97. const pendingTicket = ref<string | null>(null);
  98. const pendingMemberships = ref<MembershipView[]>([]);
  99. // 视图恢复标记:applyLogin(② 签发)或 restoreMe(/auth/me)成功后为 true;
  100. // hydrate(刷新后 token 在但视图空)/ doLogout 后置 false——守卫据此触发一次恢复。
  101. const restored = ref(false);
  102. /** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
  103. const isLoggedIn = computed(() => {
  104. if (!accessToken.value) return false;
  105. const exp = expiresAt.value;
  106. return exp === null || Date.now() < exp - 30_000;
  107. });
  108. /**
  109. * 判断是否持有指定权限码。
  110. *
  111. * 入参:
  112. * - perm string:`域.资源.动作` 三段式权限码(如 `academic.fee.refund`)。
  113. *
  114. * 出参:
  115. * - boolean:未登录恒 false(fail-closed);登录态按 permissions 集合精确匹配。
  116. */
  117. function hasPerm(perm: string): boolean {
  118. return isLoggedIn.value && permissions.value.has(perm);
  119. }
  120. /**
  121. * 登录① 成功过渡态落盘(暂存票据 + 作用域列表 + 用户名;**不写 token**)。
  122. *
  123. * 入参:
  124. * - data LoginStep1:api/auth/login.ts ① 返回的 data(code=200 时)。
  125. *
  126. * 出参:无。
  127. * 异常场景:
  128. * - data 缺 loginTicket:视为后端契约违约,过渡态不落盘(pendingTicket 保持 null)。
  129. */
  130. function applyStep1(data: LoginStep1): void {
  131. if (!data.loginTicket) return;
  132. pendingTicket.value = data.loginTicket;
  133. pendingMemberships.value = data.memberships ?? [];
  134. realName.value = data.user?.realName ?? '';
  135. }
  136. /**
  137. * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步 + scope 设置)。
  138. *
  139. * 入参:
  140. * - data TokenPair:api/auth/login.ts ② 返回的 data(code=200 时)。
  141. *
  142. * 出参:无。
  143. * 异常场景:
  144. * - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
  145. * - institutionId ≤ 0(平台级超管):scope.set 拒绝(机构必选),作用域保持未就绪——
  146. * 平台级角色的作用域语义为独立待办,本处不特殊处理(对齐现状)。
  147. */
  148. function applyLogin(data: TokenPair): void {
  149. if (!data.accessToken) return;
  150. const scope = useScope();
  151. setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
  152. accessToken.value = data.accessToken;
  153. expiresAt.value = data.expiresAt;
  154. realName.value = data.user?.realName ?? realName.value;
  155. roles.value = data.roles ?? [];
  156. permissions.value = new Set(data.permissions ?? []);
  157. menuTree.value = data.menuTree ?? [];
  158. // 选定作用域写入 scope store(守卫链第四步据此放行;institutionId>0 才合法)
  159. if (data.institutionId > 0) {
  160. scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
  161. }
  162. // ② 完成:清过渡态(票据已消费,留着反成泄露面);视图已完整 → 恢复标记置位
  163. pendingTicket.value = null;
  164. pendingMemberships.value = [];
  165. restored.value = true;
  166. // 记住本次作用域(机构>0 才记;campus 0 = 机构级也记,作为下次默认)
  167. if (data.institutionId > 0) rememberScope(data.institutionId, data.campusId ?? 0);
  168. }
  169. /**
  170. * 记住上次作用域(写 localStorage;下次登录的作用域选择页据此默认选中)。
  171. *
  172. * 入参:institutionId(>0 才记)/ campusId(0 = 机构级)。
  173. * 出参:无。
  174. * 异常场景:localStorage 不可用(隐私模式禁用):静默跳过,不影响登录流程。
  175. */
  176. function rememberScope(institutionId: number, campusId: number): void {
  177. if (!institutionId || institutionId <= 0) return;
  178. try {
  179. localStorage.setItem(LAST_SCOPE_KEY, JSON.stringify({ institutionId, campusId: campusId ?? 0 }));
  180. } catch {
  181. /* 隐私模式等 storage 禁用:忽略(偏好丢失不阻断业务) */
  182. }
  183. }
  184. /**
  185. * 读取上次作用域(作用域选择页初始化用)。
  186. *
  187. * 入参:无。
  188. * 出参:{ institutionId, campusId } | null(无记录 / 解析失败 = null,调用方回退首项)。
  189. * 异常场景:JSON 解析失败 / 结构不符:返回 null(fail-open 回退默认首项)。
  190. */
  191. function lastScope(): { institutionId: number; campusId: number } | null {
  192. try {
  193. const raw = localStorage.getItem(LAST_SCOPE_KEY);
  194. if (!raw) return null;
  195. const p = JSON.parse(raw) as { institutionId?: number; campusId?: number };
  196. if (typeof p.institutionId === 'number' && p.institutionId > 0) {
  197. return { institutionId: p.institutionId, campusId: typeof p.campusId === 'number' ? p.campusId : 0 };
  198. }
  199. return null;
  200. } catch {
  201. return null;
  202. }
  203. }
  204. /**
  205. * 凭有效 access 恢复响应式视图(GET /auth/me,菜单刷新恢复用)。
  206. *
  207. * 入参:无(作用域/角色来自已验签 JWT claims,后端按 claims 回读 + 重新解析菜单/权限)。
  208. *
  209. * 出参:
  210. * - Promise<boolean>:true = 恢复成功(roles/permissions/menuTree/scope 已同步);
  211. * false = 无 token / 恢复失败(视图保持现状:菜单全隐、scope 未就绪)。
  212. * 异常场景:
  213. * - code=3001(token 有效但用户被删,罕见):按登出处理(强制重新登录)并返回 false;
  214. * - access 过期:http 拦截器自动 refresh 重放,仍失败返回 false;
  215. * - 网络层故障:返回 false(调用方不阻塞——守卫继续走作用域选择页降级)。
  216. *
  217. * 并发纪律:同一时刻仅一个 in-flight 请求(守卫并发导航复用同一 Promise)。
  218. */
  219. async function restoreMe(): Promise<boolean> {
  220. if (!accessToken.value) return false;
  221. if (restoreInflight) return restoreInflight;
  222. restoreInflight = (async () => {
  223. try {
  224. const res = await fetchMe();
  225. if (res.code === 3001) {
  226. // token 有效但用户被删(罕见):会话作废,强制重新登录
  227. void doLogout();
  228. return false;
  229. }
  230. if (res.code !== 200 || !res.data) return false;
  231. const d = res.data;
  232. realName.value = d.realName || realName.value;
  233. roles.value = d.roles ?? [];
  234. permissions.value = new Set(d.permissions ?? []);
  235. menuTree.value = d.menuTree ?? [];
  236. if (d.institutionId > 0) {
  237. useScope().set({ institutionId: d.institutionId, campusId: d.campusId ?? 0 });
  238. }
  239. restored.value = true;
  240. return true;
  241. } catch {
  242. return false;
  243. } finally {
  244. restoreInflight = undefined;
  245. }
  246. })();
  247. return restoreInflight;
  248. }
  249. /**
  250. * 执行登出:调后端吊销 → 清本地态(无论后端是否成功,本地态必清——登出不允许卡住)。
  251. *
  252. * 入参:无。
  253. *
  254. * 出参:
  255. * - Promise<void>:恒 resolve(失败场景静默,网络错误不阻塞登出)。
  256. */
  257. async function doLogout(): Promise<void> {
  258. const refresh = getRefreshToken();
  259. try {
  260. if (refresh) await apiLogout(refresh);
  261. } catch {
  262. // 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效)
  263. }
  264. const scope = useScope();
  265. scope.reset();
  266. clearTokens();
  267. accessToken.value = null;
  268. expiresAt.value = null;
  269. realName.value = '';
  270. roles.value = [];
  271. permissions.value = new Set();
  272. menuTree.value = [];
  273. pendingTicket.value = null;
  274. pendingMemberships.value = [];
  275. restored.value = false;
  276. }
  277. /**
  278. * 从 token-store 同步本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。
  279. *
  280. * 入参:无。
  281. *
  282. * 出参:无。
  283. * 异常场景:
  284. * - 无 token(未登录):视图置空,守卫链引导到登录页。
  285. * - 有 token(刷新后):roles/permissions/menuTree 是 ② 下发的内存态(刷新即丢),
  286. * 此处仅恢复 token 三件套;完整视图恢复由守卫调 restoreMe()(/auth/me)完成。
  287. */
  288. function hydrate(): void {
  289. accessToken.value = getAccessToken();
  290. expiresAt.value = getExpiresAt();
  291. pendingTicket.value = null;
  292. pendingMemberships.value = [];
  293. // 刷新后视图未恢复(roles/menuTree/scope 全空):置 false,守卫首次导航触发 restoreMe
  294. restored.value = false;
  295. if (!accessToken.value) {
  296. realName.value = '';
  297. roles.value = [];
  298. permissions.value = new Set();
  299. menuTree.value = [];
  300. }
  301. }
  302. return {
  303. accessToken,
  304. expiresAt,
  305. realName,
  306. roles,
  307. permissions,
  308. menuTree,
  309. pendingTicket,
  310. pendingMemberships,
  311. restored,
  312. isLoggedIn,
  313. hasPerm,
  314. applyStep1,
  315. applyLogin,
  316. rememberScope,
  317. lastScope,
  318. restoreMe,
  319. doLogout,
  320. hydrate,
  321. };
  322. });