Ver código fonte

auth: PC 接入 /auth/me(刷新后恢复 roles/menuTree/scope)

刷新后 token 三件套在 token-store,但 roles/permissions/menuTree 是② 下发的内存态(不落盘)。
凭有效 access 调 GET /auth/me 按已验签 claims 回读用户+作用域+按角色重新解析菜单/权限,
恢复响应式视图:
- api/auth/me.ts 新增:fetchMe()→R<MeResult>(MeResult extends LoginUser + roles/scope/
  memberships/menuTree/permissions,对齐后端 identity MeResult;启动场景 timeout 5s)
- store/login.ts 新增 restoreMe()(in-flight 单例防并发导航重复请求;同步 realName/roles/
  permissions/menuTree + 设 scope;code=3001 用户被删→强制登出)+ restored 标记
  (applyLogin/restoreMe 成功置 true,hydrate/doLogout 置 false)
- router/index.ts 守卫加 ①.5 视图恢复步:已登录但 !restored 且需 scope 的业务页,先
  await restoreMe() 再走 ④ 作用域判定;scope-select(scopeRequired=false)跳过
门禁:vue-tsc typecheck 0 错 + eslint 0 错 + vite build 通过。
Q 3 dias atrás
pai
commit
5660212bf3
3 arquivos alterados com 136 adições e 5 exclusões
  1. 51 0
      src/api/auth/me.ts
  2. 10 0
      src/router/index.ts
  3. 75 5
      src/store/login.ts

+ 51 - 0
src/api/auth/me.ts

@@ -0,0 +1,51 @@
+/**
+ * me.ts —— 登录态自查接口封装(auth 域;一域一目录、一资源一文件纪律,README §3.2)。
+ *
+ * 用途(T6.5+ 菜单刷新恢复):
+ *   页面刷新后 token 三件套仍在 token-store,但 roles/permissions/menuTree 是 ② 响应下发的
+ *   内存态(不落盘)——凭有效 access 调 /auth/me 恢复:后端按已验签 claims 回读用户 +
+ *   当前作用域 + 全部 membership + 按角色重新解析 menuTree/permissions(与 issue 同源)。
+ *   本端点**受保护**(需 Auth 验签中间件),access 过期时 http 拦截器自动走 refresh 单例后重放。
+ *
+ * 与后端 identity/service MeResult 逐字段对齐(UserView 扁平展开 + roles/scope/memberships/menu/权限)。
+ */
+import { http } from '@/api/http';
+import type { MenuNode, R } from '@/types/api';
+import type { LoginUser, MembershipView } from './login';
+
+/** 登录态自查出参(后端 MeResult:UserView 字段扁平 + 角色/作用域/成员关系/RBAC) */
+export interface MeResult extends LoginUser {
+  /** 当前作用域的角色码(来自已验签 claims;字符串数组,通常单条) */
+  roles: string[];
+  /** 当前作用域机构 id(来自已验签 claims;0 = 平台级) */
+  institutionId: number;
+  /** 当前作用域校区 id(0 = 机构级/不限) */
+  campusId: number;
+  /** 该用户全部成员关系(切换作用域参考;菜单刷新恢复主用前两个字段) */
+  memberships: MembershipView[];
+  /** 可见导航菜单树(同 issue 下发;按选定角色过滤) */
+  menuTree: MenuNode[];
+  /** RBAC 全量权限点 code 集(含 button;v-perm 依据) */
+  permissions: string[];
+}
+
+/**
+ * 登录态自查(GET /api/auth/me,**受保护**——需有效 access token)。
+ *
+ * 入参:无(作用域/角色来自已验签 JWT claims,前端不传)。
+ *
+ * 出参:
+ *   - Promise<R<MeResult>>:
+ *     - code=200:data 为用户 + roles + 当前作用域 + memberships + menuTree + permissions;
+ *     - code=1001:access 无效(http 拦截器会先尝试 refresh 重放,仍 1001 则跳登录);
+ *     - code=3001:claims.Sub 对应用户不存在(token 有效但用户被删——前端按登出处理);
+ *     - code=5002:DB 故障。
+ *
+ * 异常场景:
+ *   - 网络层故障:Promise reject,调用方 catch(刷新恢复场景按降级处理:菜单全隐、scope 未就绪)。
+ *
+ * 说明:启动/恢复场景传短 timeout(5s)——恢复失败不应让首帧白屏 30s(全局 axios timeout)。
+ */
+export function fetchMe(): Promise<R<MeResult>> {
+  return http.get<R<MeResult>>('/api/auth/me', { timeout: 5000 }).then((r) => r.data);
+}

+ 10 - 0
src/router/index.ts

@@ -5,6 +5,8 @@
  *   ① 登录态:login.isLoggedIn(有 access 且未过期,提前 30s 判过期);
  *   ② 刷新:access 过期但有 refresh → 静默 refresh(refreshAccessToken 单例)→ 成功放行 / 失败跳登录;
  *   ③ 白名单:登录页/SSO 回调等公开路由直接放行(在 Auth 守卫之前,对齐后端"公开路由先注册"纪律);
+ *   ①.5 视图恢复:已登录但响应式视图未恢复(刷新后 token 在、roles/menuTree/scope 空)
+ *      → 凭 access 调一次 /auth/me(login.restoreMe,store 内 in-flight 单例)恢复菜单/权限/作用域;
  *   ④ 作用域:路由 meta.scopeRequired(默认 true)且 scope 未就绪 → 跳作用域选择页。
  *
  * 路由 path 与后端端点对齐(`/{端}/{模块}/{资源}`,README §3.1);市场角色登录入口暂禁用——
@@ -136,6 +138,14 @@ export function setupGuards(router: ReturnType<typeof buildRouter>): void {
       return { name: 'Login' };
     }
 
+    // ①.5 视图恢复:已登录但响应式视图未恢复(页面刷新后 token 在、roles/menuTree/scope 空)
+    //     → 凭 access 调一次 /auth/me 恢复(store 内 in-flight 单例,防并发导航重复请求)。
+    //     仅作用于需要 scope 的业务页;scope-select 本身不依赖 roles/menuTree(只凭 token),跳过。
+    //     恢复失败(用户被删→store 已登出;网络故障→保持未恢复):继续走 ④ 作用域判定兜底。
+    if (!login.restored && to.meta.scopeRequired !== false) {
+      await login.restoreMe();
+    }
+
     // ④ 作用域就绪
     if (to.meta.scopeRequired !== false && !scope.ready) {
       return { name: 'ScopeSelect' };

+ 75 - 5
src/store/login.ts

@@ -20,6 +20,7 @@
  *   <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';
@@ -47,6 +48,8 @@ export interface LoginStore {
   pendingTicket: Ref<string | null>;
   /** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
   pendingMemberships: Ref<MembershipView[]>;
+  /** 响应式视图是否已从后端恢复(② 签发或 /auth/me 成功);刷新后为 false,守卫据此触发 restoreMe 一次 */
+  restored: Ref<boolean>;
   /** 是否已登录(有 access token 且未过期;守卫链第一步) */
   isLoggedIn: ComputedRef<boolean>;
   /** 判断是否持有指定权限码(v-perm 指令用) */
@@ -55,12 +58,26 @@ export interface LoginStore {
   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>;
   /** 执行登出(调后端吊销 → 清本地态) */
   doLogout: () => Promise<void>;
   /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
   hydrate: () => void;
 }
 
+// /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);
@@ -73,6 +90,9 @@ export const useLogin = defineStore('login', (): LoginStore => {
   // 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
   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(() => {
@@ -137,9 +157,56 @@ export const useLogin = defineStore('login', (): LoginStore => {
     if (data.institutionId > 0) {
       scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
     }
-    // ② 完成:清过渡态(票据已消费,留着反成泄露面)
+    // ② 完成:清过渡态(票据已消费,留着反成泄露面);视图已完整 → 恢复标记置位
     pendingTicket.value = null;
     pendingMemberships.value = [];
+    restored.value = true;
+  }
+
+  /**
+   * 凭有效 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;
   }
 
   /**
@@ -168,6 +235,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
     menuTree.value = [];
     pendingTicket.value = null;
     pendingMemberships.value = [];
+    restored.value = false;
   }
 
   /**
@@ -178,16 +246,16 @@ export const useLogin = defineStore('login', (): LoginStore => {
    * 出参:无。
    * 异常场景:
    *   - 无 token(未登录):视图置空,守卫链引导到登录页。
-   *   - 有 token(刷新后):roles/permissions/menuTree 未知(② 过渡态不落盘,刷新即丢),
-   *     完整恢复依赖后续 /api/auth/me 端点(规划项);过渡态恒置空。
+   *   - 有 token(刷新后):roles/permissions/menuTree 是 ② 下发的内存态(刷新即丢),
+   *     此处仅恢复 token 三件套;完整视图恢复由守卫调 restoreMe()(/auth/me)完成。
    */
   function hydrate(): void {
     accessToken.value = getAccessToken();
     expiresAt.value = getExpiresAt();
     pendingTicket.value = null;
     pendingMemberships.value = [];
-    // 刷新后 roles/permissions 未知(后端未提供"凭 access 取用户中心资料"端点前),
-    // 首次请求 401 触发 refresh 流程;完整恢复依赖后续 /api/auth/me 端点(规划项)。
+    // 刷新后视图未恢复(roles/menuTree/scope 全空):置 false,守卫首次导航触发 restoreMe
+    restored.value = false;
     if (!accessToken.value) {
       realName.value = '';
       roles.value = [];
@@ -205,10 +273,12 @@ export const useLogin = defineStore('login', (): LoginStore => {
     menuTree,
     pendingTicket,
     pendingMemberships,
+    restored,
     isLoggedIn,
     hasPerm,
     applyStep1,
     applyLogin,
+    restoreMe,
     doLogout,
     hydrate,
   };