陌上人如玉
公子世无双

6.12 路由工程化:权限矩阵与动态菜单

6.12 路由工程化:权限矩阵与动态菜单

一句话:前端权限的定位是「体验优化,不是安全边界」——它的正确做法是「后端下发权限点(不是角色),前端用权限点做三件事:路由守卫、菜单过滤、按钮/字段级控制」,而最容易出错的地方是「把『无权限』和『404』混为一谈」以及「动态路由带来的『白屏、闪烁、缓存失效』」。

一、先立规矩:前端权限能做什么、不能做什么

⭐⭐⭐⭐ 前端权限的两条铁律(面试必答):

① 【前端权限「不是安全边界」】⭐
   前端做的所有权限判断(隐藏按钮、拦截路由)**都能被绕过**:
     · 用户可以用 DevTools 改 JS 变量
     · 可以直接调接口(curl/Postman)
     · 可以改 URL 强行访问
   → ⭐ **真正的校验必须在后端**(每个接口都要校验「这个用户能不能做这件事」)
   → 前端的权限只是「**让用户看不到/点不到他不该用的东西**」(体验 + 减少无效操作)

② 【用「权限点」而不是「角色」做判断】⭐
   ❌ 前端到处写 `if (user.role === 'admin')`
     → 问题:加一个「财务」角色就要改前端所有地方;
       而且「管理员能做的一切」与「某个人只有部分管理权限」无法区分
   ✅ 后端下发**权限点数组**(如 `['product:read', 'product:write', 'order:refund']`)
     → 前端只判断「有没有这个权限点」:
        `if (can('order:refund')) { ... }`
     ⭐ 收益:「角色 → 权限点」的映射在**后端/配置中心**维护,
       前端代码**不需要因「组织架构调整」而改动**。
⭐⭐⭐ 权限控制的三种粒度(缺一个都会漏):

① 【路由级】——「能不能进这个页面」
   ✅ 在「路由守卫」里判断(⭐ 在「渲染之前」——见 6.11 的 beforeLoad)
   ⚠️ 只做路由级不够:「有权限进页面」不代表「页面里每个按钮都能点」

② 【组件/元素级】——「这个按钮 / 这一列 / 这个字段 显不显示」
   ✅ 用 `<Can permission="order:refund">…</Can>` 包裹,或 `can()` 判断
   ⚠️ 注意「只隐藏不拦截」的反例:如果后端不校验,用户直接调接口依然能改

③ 【数据级】——「这一行数据我能不能看 / 改」
   ⭐ 这**必须在后端做**(前端拿到的数据要么「已经过滤好」,要么「带一个 canEdit 标记」)
   ⚠️ 前端拿全量数据再过滤 = 「用户能在 Network 面板里看到不该看的数据」
   ✅ 正确做法:后端按权限返回「字段/行」的可见性与可写性
      (如「返回 `salary: null` + `salaryVisible: false`」,而不是「返回后前端隐藏」)

二、权限模型:RBAC 与权限点

// ============ 1. 后端下发的「用户信息」(含权限点) ============
interface CurrentUser {
  id: string;
  name: string;
  /** ⭐ 角色(仅用于展示/分组,不用于「前端判断」) */
  roles: string[];
  /** ⭐⭐ 权限点(前端所有判断都基于它) */
  permissions: string[];
}

// 一个真实项目的权限点设计(⭐ 命名规范:`资源:动作`,可带「范围」)
const PERMISSIONS = {
  PRODUCT_READ: 'product:read',
  PRODUCT_WRITE: 'product:write',
  PRODUCT_DELETE: 'product:delete',
  ORDER_READ: 'order:read',
  ORDER_REFUND: 'order:refund',
  // ⭐ 「数据范围」也可以编码进权限点(或单独一个字段)
  ORDER_READ_ALL: 'order:read:all',            // 能看「所有人」的订单
  ORDER_READ_SELF: 'order:read:self',          // 只能看「自己」的订单
  USER_MANAGE: 'user:manage',
  SETTINGS_BILLING: 'settings:billing',
} as const;

type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
// ============ 2. 权限判断的核心:一个 Context + 三个 API ============
import { createContext, useContext, useMemo, useCallback } from 'react';

interface AuthContextValue {
  user: CurrentUser | null;
  isLoggedIn: boolean;
  /** ⭐ 核心判断(支持「单个 / 数组(任一) / 函数」) */
  can: (requirement: Permission | Permission[] | ((p: (x: Permission) => boolean) => boolean)) => boolean;
  /** ⭐ 要求「全部满足」 */
  canAll: (requirements: Permission[]) => boolean;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ user, children }: { user: CurrentUser | null; children: React.ReactNode }) {
  const permissionSet = useMemo(() => new Set(user?.permissions ?? []), [user?.permissions]);

  const has = useCallback((p: Permission) => permissionSet.has(p), [permissionSet]);

  const can = useCallback<AuthContextValue['can']>(
    (requirement) => {
      if (typeof requirement === 'function') return requirement(has);
      if (Array.isArray(requirement)) return requirement.some(has);      // ⭐ 数组 = 「任一」
      return has(requirement);
    },
    [has]
  );

  const canAll = useCallback((reqs: Permission[]) => reqs.every(has), [has]);

  const value = useMemo<AuthContextValue>(
    () => ({ user, isLoggedIn: Boolean(user), can, canAll }),
    [user, can, canAll]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth 必须在 <AuthProvider> 内使用');
  return ctx;
}

/** ⭐ 便捷 Hook:只取 can */
export function usePermission() {
  const { can, canAll } = useAuth();
  return { can, canAll };
}
// ============ 3. 元素级:<Can> 组件(比散落的 if 更可读) ============
interface CanProps {
  /** ⭐ 需要的权限(单个 / 数组=任一) */
  permission?: Permission | Permission[];
  /** ⭐ 需要全部 */
  all?: Permission[];
  /** 无权限时的替代内容(默认不渲染) */
  fallback?: React.ReactNode;
  children: React.ReactNode;
}

export function Can({ permission, all, fallback = null, children }: CanProps) {
  const { can, canAll } = useAuth();

  const allowed = (() => {
    if (all?.length) return canAll(all);
    if (permission === undefined) return true;
    return can(permission);
  })();

  if (!allowed) return <>{fallback}</>;

  // ⭐ 注意「只隐藏不拦截」的边界:
  //    如果一个「操作按钮」被隐藏但接口没校验 → 用户仍能通过接口完成
  //    ⭐ 所以 <Can> 是「体验优化」,真正的安全在后端
  return <>{children}</>;
}

// 用法:一个「订单行」的操作按钮
function OrderRowActions({ order, onRefund, onView }: { order: Order; onRefund: () => void; onView: () => void }) {
  return (
    <div>
      <button onClick={onView}>查看</button>

      {/* ⭐ 只有「有退款权限」才显示退款按钮 */}
      <Can permission={PERMISSIONS.ORDER_REFUND}>
        <button onClick={onRefund}>退款</button>
      </Can>

      {/* ⭐ 禁用而不是隐藏(当「功能存在但当前对象不允许」时) */}
      <Can
        permission={PERMISSIONS.ORDER_REFUND}
        fallback={<button disabled title="你没有退款权限">退款</button>}
      >
        <button onClick={onRefund}>退款</button>
      </Can>
    </div>
  );
}
⭐⭐⭐ 「隐藏」还是「禁用」?——一个常被搞混的产品决策:

   【隐藏】✅ 适合「这个用户永远不会用到的功能」
     (如「财务」在运营的界面上完全不显示)
   【禁用 + 提示原因】✅ 适合「功能存在,只是当前条件不满足」
     (如「有退款权限,但这个订单已超出退款期」)
   ⭐ 判据:「**用户知道『这个功能存在』会不会有帮助**」——
     会(如「他看到别人能退款,知道自己缺权限,去找管理员开通」)→ 禁用 + 说明
     不会(只会增加困惑)→ 隐藏
   ⚠️ 两种都不影响「后端必须校验」这条铁律。

三、路由级:静态路由 vs 动态路由

⭐⭐⭐ 「动态路由」(后端下发菜单 → 前端生成路由)的取舍:

【静态路由(路由表写在前端,用权限「过滤菜单」)】⭐ 推荐
   ✅ 简单、可控、构建时就能做代码分割
   ✅ 「404 页面」与「无权限」容易区分
   ✅ 类型安全(路径是字面量)
   ⚠️ 新增页面需要「前端发版」
   ⭐ 适合:绝大多数后台系统(菜单与权限点「一一对应」)

【动态路由(后端返回菜单树,前端动态 addRoute)】
   ✅ 「后端配置菜单,前端不用发版」(⭐ 这是它唯一的强收益)
   ⚠️ 代价很大:
     · 「路由组件」要「按字符串动态映射」(`component: 'UserList'` → 一个映射表)
       ⭐ 且「动态导入 + 类型安全」都会变难
     · 首屏「白屏」风险(要先拿菜单再挂路由)
     · 「404」与「无权限」难区分(路由根本不存在 vs 存在但没权限)
     · 「缓存/预加载/代码分割」都变复杂
     · 前端「路由结构」被后端的配置格式绑架
   ⭐ 适合:「菜单项极多且频繁变化」(如「平台化 SaaS」「大后台」)
     —— 但即便这样,也常做成「静态路由 + 后端只控制『哪些菜单显示』」
// ✅ 推荐做法:静态路由表 + 权限过滤(而不是「动态生成路由」)
interface AppRoute {
  path: string;
  /** ⭐ 访问所需权限(不写 = 登录即可) */
  permission?: Permission;
  /** 是否需要登录 */
  requiresAuth?: boolean;
  /** 菜单显示信息(不写 = 不在菜单里显示,如「详情页」) */
  menu?: { title: string; icon?: string; order?: number; hidden?: boolean };
  children?: AppRoute[];
}

const routes: AppRoute[] = [
  {
    path: '/',
    menu: { title: '仪表盘', icon: 'dashboard', order: 0 },
    // 登录即可
  },
  {
    path: '/products',
    permission: PERMISSIONS.PRODUCT_READ,
    menu: { title: '商品管理', icon: 'box', order: 10 },
    children: [
      { path: '/products/new', permission: PERMISSIONS.PRODUCT_WRITE, menu: { title: '新建商品' } },
      // ⭐ 「详情页」不进菜单(hidden)
      { path: '/products/:id', permission: PERMISSIONS.PRODUCT_READ },
    ],
  },
  {
    path: '/orders',
    permission: [PERMISSIONS.ORDER_READ_ALL, PERMISSIONS.ORDER_READ_SELF],   // ⭐ 任一
    menu: { title: '订单', icon: 'list', order: 20 },
  },
  {
    path: '/settings',
    permission: PERMISSIONS.USER_MANAGE,
    menu: { title: '设置', icon: 'gear', order: 90 },
    children: [
      { path: '/settings/users', permission: PERMISSIONS.USER_MANAGE, menu: { title: '用户管理' } },
      { path: '/settings/billing', permission: PERMISSIONS.SETTINGS_BILLING, menu: { title: '账单' } },
    ],
  },
];

/** ⭐ 菜单树:由权限过滤(「父节点没权限 → 整棵子树不显示」) */
function buildMenu(routes: AppRoute[], can: (p: Permission | Permission[]) => boolean) {
  const out: Array<{ title: string; path: string; icon?: string; children?: unknown[] }> = [];

  for (const route of routes) {
    if (!route.menu || route.menu.hidden) continue;              // ⭐ 不进菜单的跳过
    // ⭐ 权限过滤(父级不通过 → 子级也不显示)
    if (route.permission && !can(route.permission)) continue;

    const children = route.children ? buildMenu(route.children, can) : undefined;

    // ⚠️ 细节:如果「父节点本身是分组、没有实际页面」,且「子节点全都被过滤掉了」
    //    → 就不该显示这个空分组
    if (route.children?.length && (!children || children.length === 0)) continue;

    out.push({
      title: route.menu.title,
      path: route.path,
      icon: route.menu.icon,
      children: children?.length ? children : undefined,
    });
  }

  return out.sort((a, b) => {
    const oa = findOrder(routes, a.path) ?? 0;
    const ob = findOrder(routes, b.path) ?? 0;
    return oa - ob;
  });
}

function findOrder(routes: AppRoute[], path: string): number | undefined {
  for (const r of routes) {
    if (r.path === path) return r.menu?.order;
    if (r.children) {
      const found = findOrder(r.children, path);
      if (found !== undefined) return found;
    }
  }
  return undefined;
}

四、完整实现:路由守卫 + 菜单 + 403/404 区分(250 行)

// auth-routing.tsx —— 一个完整的「权限路由」实现
import { useMemo, useCallback } from 'react';
import { createBrowserRouter, Navigate, Outlet, useLocation, useRouteError, isRouteErrorResponse } from 'react-router-dom';

/** ============ 1. 权限判断(复用上面的 useAuth) ============ */
// (省略,见上文 AuthProvider / useAuth)

/** ============ 2. 一个「守卫」组件:渲染前判断 ⭐ ============ */
/**
 * ⭐⭐ 关键设计:守卫「不渲染 children」直到权限确定,
 *    避免「先渲染受保护内容再跳走」(闪一下 + 可能的请求泄漏)。
 *    ⚠️ 与 TanStack Router 的 beforeLoad 等价的东西在 React Router 里没有,
 *       所以只能「用组件包裹」—— 这也是「TanStack Router 更干净」的一个体现。
 */
function RequireAuth({ permission, children }: { permission?: Permission | Permission[]; children: React.ReactNode }) {
  const { isLoggedIn, can, user } = useAuth();
  const location = useLocation();

  // ⭐ ① 「会话还在恢复中」(如「用 refresh token 换 access token」)
  //    → 渲染 loading(⭐ 而不是「先跳登录页再跳回来」——那样会闪烁)
  if (user === undefined) {
    return <FullPageSpinner label="正在恢复登录状态…" />;
  }

  // ⭐ ② 未登录 → 去登录页(带上「我想去哪」)
  if (!isLoggedIn) {
    return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
  }

  // ⭐③ 已登录但无权限 → 明确的 403(⭐ 不是 404、也不是「跳首页」)
  if (permission && !can(permission)) {
    return <ForbiddenPage requiredPermission={permission} />;
  }

  return <>{children}</>;
}

/** ============ 3. 403 / 404 的明确区分 ⭐ ============ */
function ForbiddenPage({ requiredPermission }: { requiredPermission?: Permission | Permission[] }) {
  return (
    <div role="alert" style={{ padding: 40, textAlign: 'center' }}>
      <h1>403 · 没有访问权限</h1>
      你的账号没有访问该页面的权限。
      {/* ⭐ 明确告诉用户「缺什么权限」——这对他找管理员开通很有帮助 */}
      {requiredPermission && (
        <p style={{ color: '#888', fontSize: 12 }}>
          需要的权限:{Array.isArray(requiredPermission) ? requiredPermission.join(' 或 ') : requiredPermission}
        </p>
      )}
      <a href="/">返回首页</a>
    </div>
  );
}

function NotFoundPage() {
  return (
    <div style={{ padding: 40, textAlign: 'center' }}>
      <h1>404 · 页面不存在</h1>
      链接可能已失效,或者你输入了错误的地址。
      <a href="/">返回首页</a>
    </div>
  );
}

/** ⭐ 路由错误边界:把「路由层的错误」与「业务错误」分开 */
function RouteErrorBoundary() {
  const error = useRouteError();

  // ⭐ React Router 的「路由错误」(404、loader 抛错)
  if (isRouteErrorResponse(error)) {
    if (error.status === 404) return <NotFoundPage />;
    if (error.status === 403) return <ForbiddenPage />;
    return <div role="alert">加载失败:{error.status} {error.statusText}</div>;
  }

  return <div role="alert">出错了:{String(error)}</div>;
}
/** ============ 4. 路由表(⭐ 权限在「路由定义」里声明,而不是散在组件里) ============ */
function buildRouter() {
  return createBrowserRouter([
    { path: '/login', element: <LoginPage /> },

    {
      // ⭐⭐⭐ 应用外壳(所有受保护路由的父级)
      path: '/',
      element: (
        <RequireAuth>
          <AppLayout>
            <Outlet />
          </AppLayout>
        </RequireAuth>
      ),
      errorElement: <RouteErrorBoundary />,
      children: [
        { index: true, element: <DashboardPage /> },

        {
          path: 'products',
          element: (
            // ⭐ 子路由「再包一层」(细粒度权限)
            <RequireAuth permission={PERMISSIONS.PRODUCT_READ}>
              <Outlet />
            </RequireAuth>
          ),
          errorElement: <RouteErrorBoundary />,
          children: [
            { index: true, element: <ProductListPage /> },
            {
              path: 'new',
              element: (
                <RequireAuth permission={PERMISSIONS.PRODUCT_WRITE}>
                  <ProductNewPage />
                </RequireAuth>
              ),
            },
            { path: ':id', element: <ProductDetailPage /> },
          ],
        },

        {
          path: 'orders',
          element: (
            <RequireAuth permission={[PERMISSIONS.ORDER_READ_ALL, PERMISSIONS.ORDER_READ_SELF]}>
              <OrderListPage />
            </RequireAuth>
          ),
        },

        { path: 'settings/contributing', element: <RequireAuth permission={PERMISSIONS.SETTINGS_BILLING}><BillingPage /></RequireAuth> },

        // ⭐ 兜底 404(放在最后)
        { path: '*', element: <NotFoundPage /> },
      ],
    },
  ]);
}
/** ============ 5. 一个「会做数据级权限」的列表页(⭐ 关键在「后端返回可写标记」) ============ */
interface OrderRow {
  id: string;
  amount: number;
  status: 'pending' | 'paid' | 'refunded';
  /** ⭐⭐ 后端返回「这一行『我』能不能操作」——而不是前端「凭角色猜」 */
  canRefund: boolean;
  canViewDetail: boolean;
}

function OrderListPage() {
  const { can } = useAuth();
  const { data, isLoading } = useOrders();          // 数据层(见 7.4)

  if (isLoading) return <TableSkeleton rows={8} />;

  return (
    <table>
      <thead>
        <tr>
          <th>订单号</th>
          <th>金额</th>
          <th>状态</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {data?.items.map((row) => (
          <tr key={row.id}>
            <td>{row.id}</td>
            <td>{row.amount}</td>
            <td>{row.status}</td>
            <td>
              {/* ⭐⭐⭐ 数据级:用「后端给的行级标记」,而不是「前端再判断一次」 */}
              <button disabled={!row.canViewDetail}>查看</button>
              <button disabled={!row.canRefund}>退款</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
⭐⭐⭐ 这套实现里的五个「关键决定」:

① 【守卫「在渲染前」判断(`RequireAuth` 直接 `return <Navigate>`)】
   ⭐ 效果:「未登录/无权限时,受保护的组件【一次都不渲染】」——
     避免「闪一下受保护内容」+「那一帧里泄漏请求」。

② 【明确区分「会话恢复中 / 未登录 / 无权限」(三种状态)】
   ⭐ 这是「体验」的关键:
     · 「会话还在恢复」→ 渲染 loading(**不要**先跳登录页)
     · 「未登录」→ 跳登录页(带 `redirect`)
     · 「已登录但无权限」→ **403 页面**(不是 404、也不是「静默跳首页」)
   ⚠️ 把三者混在一起是最常见的错误(用户会「莫名其妙回到首页」)。

③ 【403 与 404 分开】
   404 = 「这个路径不存在」(路由没匹配到)
   403 = 「路径存在,但你没权限」
   ⭐ 混为一谈的后果:用户「不知道该找管理员开权限,还是该检查链接」
   ⚠️ 但也有团队「故意把 403 显示成 404」(避免泄露「某页面存在」)——
     这是**安全 vs 体验**的取舍,⭐ 需要**明确决策并写进文档**。

④ 【菜单由「同一份路由表 + 权限」生成】
   ⭐ 好处:「菜单」与「路由」不会不一致
     (否则会出现「菜单里有但点进去 403」或「能访问但菜单没入口」)
   ⚠️ 注意「空分组」的处理(父节点没权限 / 子节点全被过滤 → 不显示分组)。

⑤ 【数据级权限靠「后端返回行级标记」】
   `canRefund` / `canViewDetail` 由后端算好 →
   ⭐ 好处:前端不需要「复制一份权限规则」(规则只在后端一处)
   ⚠️ 反例:「前端拿全量数据 + 前端按角色过滤」→
     ① 规则重复维护;② **敏感数据已经在 Network 里可见**(安全漏洞)。

五、本篇特有的坑

// ① 用「角色」做前端判断(加角色就要改前端到处是 if)
if (user.role === 'admin') { ... }                        // ❌
// ✅ if (can('product:write')) { ... }(角色→权限点的映射在后端)

// ② 把「无权限」当成「跳首页」(用户不知道发生了什么)
if (!can('x')) return <Navigate to="/" />;                // ⚠️ 静默跳走
// ✅ 明确的 403 页面

// ③ 「无权限」与「404」混为一谈(用户不知道该怎么办)
// ✅ 分开(或「明确决定要合并」并写进文档)

// ④ 「未登录」时「先渲染再跳」(闪一下 + 可能发请求)
useEffect(() => { if (!isLoggedIn) navigate('/login'); }, []);   // ❌
// ✅ 在渲染前 return <Navigate />

// ⑤ 「会话恢复中」也当「未登录」(→ 用户看到「登录页闪一下」)
// ⚠️ 症状:刷新页面时「先跳登录页,再跳回来」
// ✅ 用「三态」:恢复中(loading)/ 未登录 / 已登录

// ⑥ 登录后没跳回原地址(用户还得自己再点一次)
// ✅ `?redirect=...` + 登录成功后校验「是否同源」再跳(⭐ 防开放重定向)

// ⑦ 「菜单」与「路由表」各写一份(必然不一致)
// ✅ 同一份数据源生成(见上文的 buildMenu)

// ⑧ 「空分组」还显示(父节点无权限但菜单里留了个空壳)
// ✅ 过滤后为空就不显示

// ⑨ 「隐藏按钮」但「接口没校验」(安全漏洞)⭐
// ⭐ 这是最常见的「假权限」
// ✅ 每个接口都要校验(前端 <Can> 只是体验)

// ⑩ 「前端过滤数据行」(敏感数据已下发)
const visible = orders.filter((o) => can('order:read:all') || o.userId === me.id);   // ⚠️
// ✅ 后端按权限返回(行/字段级)

// ⑪ 把「权限点」编码成「数字位运算」(难读、难维护)
const FLAG_REFUND = 1 << 3;                                // ⚠️
// ✅ 用字符串(`order:refund`)—— 可读、可搜索、可配

// ⑫ 「权限点」粒度太粗(`admin` 一个点走天下)
// ✅ 按「资源:动作」(必要时加「范围」:`order:read:all` / `order:read:self`)

// ⑬ 「权限点」粒度太细(几百个点,配置困难)
// ✅ 平衡点:「一个页面/一个操作对应 1~2 个点」

// ⑭ 「权限变更后」前端不刷新(管理员改了权限,用户还在旧状态)
// ✅ 提供「刷新权限」的入口 + 「401/403 时拉一次最新用户信息」

// ⑮ 动态路由的「白屏」(要先拿菜单再挂路由)
// ⭐ 这是动态路由的主要代价
// ✅ 若必须用:先渲染「应用外壳 + loading」,菜单到了再挂子路由

// ⑯ 动态路由的「路由组件靠字符串映射」(类型不安全 + 难以代码分割)
component: 'UserList'                                      // ⚠️
// ✅ 用「静态路由表 + 权限过滤」(推荐)

// ⑰ 「权限判断」写在「渲染热路径」上(每次都遍历大数组)
permissions.includes(p)                                    // ⚠️ 数组 O(n)
// ✅ 用 Set(O(1))+ useMemo 缓存

// ⑱ 「403 页面」上没有「我该找谁」的信息
// ✅ 显示「需要的权限」+「联系管理员」入口(⭐ 减少支持成本)

// ⑲ 「前端权限」当成「安全措施」写进设计文档 ⭐
// ⭐ 这会「误导后来者」
// ✅ 文档里明确写「前端权限 = 体验优化;安全校验在后端」

// ⑳ 「权限点命名」混乱(`readProduct` / `product_read` / `PRODUCT-READ`)
// ✅ 统一规范(推荐 `资源:动作[:范围]` 小写冒号分隔)
// ⑥ 的完整处理(防「开放重定向」)
function LoginPage() {
  const [params] = useSearchParams();
  const navigate = useNavigate();
  const redirectTo = params.get('redirect');

  const onSuccess = useCallback(() => {
    // ⭐⭐ 必须校验「是不是站内路径」(否则 `?redirect=https://evil.com` 就是钓鱼入口)
    const target = safeRedirect(redirectTo);
    void navigate(target, { replace: true });
  }, [redirectTo, navigate]);

  return <LoginForm onSuccess={onSuccess} />;
}

function safeRedirect(raw: string | null): string {
  if (!raw) return '/';
  try {
    // ⭐ 用「相对当前 origin 解析」再比较 origin
    const url = new URL(raw, window.location.origin);
    if (url.origin !== window.location.origin) return '/';
    // ⚠️ 额外防护:拒绝「协议相对 URL」(`//evil.com`)
    if (raw.startsWith('//')) return '/';
    return url.pathname + url.search + url.hash;
  } catch {
    return '/';
  }
}
// ⑭ 的完整处理(权限变更后刷新)
// ① 在「axios 拦截器」里:遇到 403 → 拉一次最新用户信息
axios.interceptors.response.use(undefined, async (error) => {
  if (error.response?.status === 403) {
    // ⭐ 可能是「权限变了」→ 刷新用户信息(含权限点)
    await authStore.refreshUser();
  }
  if (error.response?.status === 401) {
    // ⭐ 未登录/过期 → 清态 + 跳登录(⭐ 不要在这里「自动重试」造成死循环)
    authStore.logout();
  }
  return Promise.reject(error);
});

// ② 提供「手动刷新权限」入口(放在用户菜单里)
// ③ 「权限点变化」时(如管理员在另一个标签页改了)→ 见 3.14 的跨标签页同步

六、面试延伸

  1. 「前端怎么做权限控制?」
    三层粒度 + 一条铁律。铁律:⭐ 前端权限不是安全边界(能改 JS、能直接调接口、能改 URL),真正的校验必须在后端——前端做的是「体验优化(让用户看不到/点不到不该用的东西)+ 减少无效操作」。三层粒度:① 路由级——在「渲染之前」判断(React Router 用包裹组件、TanStack Router 用 beforeLoad),未登录 → 登录页、已登录但无权限 → 403;② 元素级——<Can permission="order:refund"> 或 can(),⚠️ 并明确「隐藏 vs 禁用」的产品决策;③ ⭐ 数据级——必须后端做(返回「行/字段」的可见性与可写性标记,如 canRefund),❌ 前端拿全量再过滤会导致「敏感数据已在 Network 可见」。

  2. 「为什么用『权限点』而不是『角色』?」
    因为「角色是组织的概念,权限点才是代码该依赖的东西」。用角色的问题:① 前端到处 if (user.role === 'admin') → 加一个新角色就要改前端所有地方;② 「角色」粒度太粗——「都是管理员,但财务只能看账单、运营只能看订单」无法区分;③ 「组织架构调整」会引发前端代码改动。用权限点的好处:⭐ 「角色 → 权限点」的映射在后端/配置中心维护,前端只判断「有没有这个点」(can('order:refund'))——于是「组织调整」不需要改前端。命名规范建议「资源:动作[:范围]」小写冒号分隔(如 order:read:all)。⚠️ 粒度要平衡(太粗一个 admin 走天下、太细几百个点难配置),实践上「一个页面/一个操作对应 1~2 个点」。

  3. 「403 和 404 该怎么区分?」
    语义上必须分清:404 = 「这个路径不存在」(路由没匹配到);403 = 「路径存在,但你没权限」。⭐ 混为一谈的后果是「用户不知道该找管理员开权限,还是该检查链接」,支持成本上升。三种处理策略:① 分开(推荐)——403 页面明确写「你缺少 order:refund 权限」+「联系管理员」入口,⭐ 减少支持成本;② ⭐ 故意把 403 显示成 404——用于「不想泄露『某页面存在』」的安全场景(如「内部管理后台的入口」),这是安全 vs 体验的取舍;③ 「无权限 → 静默跳首页」❌——最差(用户完全不知道为什么)。⭐ 关键是要「明确决策并写进文档」,而不是「随手写一个」。

  4. 「『未登录』和『无权限』的守卫怎么写才不闪?」
    关键是「在渲染之前判断」:① ⭐ React Router——用「包裹组件」RequireAuth,直接 return <Navigate to="/login">(不渲染 children)——但注意它不能阻止「父路由已经渲染」,所以「外壳(导航栏)」仍可能出现;② ⭐ TanStack Router——用 beforeLoad 里 throw redirect(...),路由的组件根本不会被渲染(更干净);③ ⭐ 三态处理(最重要):「会话恢复中」→ loading /「未登录」→ 登录页 /「已登录但无权限」→ 403。⚠️ ❌ 最常见错误是把「会话恢复中」也当成「未登录」→ 用户刷新页面时「先看到登录页闪一下,再跳回来」。

  5. 「动态路由(后端返回菜单)值得做吗?」
    ⭐ 大多数情况不值得。它唯一的强收益是「后端配菜单,前端不用发版」。代价很大:① 路由组件要靠字符串映射(component: 'UserList')→ 类型不安全 + 代码分割困难;② 首屏白屏风险(要先拿菜单再挂路由,⭐ 常见做法是「先渲染外壳 + loading」);③ ⭐ 「404」与「无权限」难区分(「路由根本没注册」vs「注册了但没权限」);④ 缓存/预加载/TS 类型都变复杂;⑤ 前端路由结构被后端的配置格式绑架。推荐做法:⭐ 「静态路由表 + 权限过滤菜单」——路由写在前端(可控、可类型检查、可代码分割),后端只控制「权限点」(进而决定「哪些菜单显示」)。唯一例外:「菜单项极多且频繁变化的平台化 SaaS」——但即便这样也常做成「静态路由 + 后端只控制菜单显示」。

  6. 「菜单和路由怎么保持一致?」
    ⭐ 用「同一份数据源」:定义一份「路由表(含 path/permission/menu 元信息)」,然后:① 路由注册从它生成;② 菜单树用「同一份数据」经过「权限过滤」生成;③ 面包屑/标题/权限也从它读。好处:⭐ 不会出现「菜单里有但点进去 403」或「能访问但菜单没入口」。两个细节:① 「不进菜单」的路由要显式标记(详情页、新建页——用 menu.hidden 或「不写 menu」);② ⭐ 「空分组」要摘掉——「父节点没权限」或「子节点全被过滤」时,不该留下一个空壳分组(⚠️ 这是很常见的体验缺陷)。

  7. 「前端权限的『安全建议』有哪些?」
    五条:① ⭐ 所有接口都必须校验权限(后端是唯一的安全边界)——前端隐藏按钮只影响体验;② ⭐ 敏感数据不要在「下发给前端后再过滤」——要么「后端按权限返回」(行/字段级),要么「返回时就不带上」(如「不可见时返回 salary: null + salaryVisible: false」);③ ⭐ 防开放重定向——?redirect=... 必须校验「是否同源」(否则是「钓鱼入口」);④ token 存储——优先 HttpOnly Cookie(JS 读不到,⭐ 减少 XSS 窃取,见 8.2);⑤ ⭐ 把「前端权限 = 体验优化」写进设计文档——避免后来者误以为「前端拦住了就是安全的」。另外:「403 时拉一次最新用户信息」(应对「权限被后台改了」)、「权限点用字符串而不是位运算」(可读可配)。

  8. 「如果后端只有『角色』,前端怎么办?」
    分三种情况:① ⭐ 能推动后端改——最好(让后端下发权限点,前端只判断点);② 暂时不能改——在前端建一层「角色 → 权限点」的映射表(⭐ 集中在一处,如 rolesToPermissions.ts),并在代码里只用权限点判断(can('order:refund') 内部去查映射)——这样「以后后端改成下发权限点时,只改这一处」;③ 连映射都做不了(如「权限规则极其动态,前端无法静态描述」)——那就把判断交给后端:让后端在「页面数据接口」里返回「行级/字段级的可操作标记」(canRefund),前端只渲染后端给的标记(⭐ 这是最稳的:规则只在后端一处,且天然安全)。

一句话速记

⭐ 铁律:前端权限不是安全边界(能改 JS / 直接调接口 / 改 URL)——真正的校验在后端,前端做的是「体验优化 + 减少无效操作」;⭐ 用「权限点」不用「角色」(order:refund,命名 资源:动作[:范围])——「角色 → 权限点」的映射在后端维护,组织调整不改前端;三层粒度:路由级(在「渲染之前」判断,RequireAuth 直接 return <Navigate> / TanStack beforeLoad 抛 redirect)+ 元素级(<Can>,并明确「隐藏 vs 禁用」的产品决策)+ ⭐ 数据级(必须后端返回「行/字段级可操作标记」,❌ 前端拿全量再过滤 = 敏感数据已在 Network 可见);⭐ 必须区分三种状态:「会话恢复中 → loading(❌ 当成未登录会导致「刷新页面先闪登录页」)/ 未登录 → 登录页(带 redirect,⭐ 必须校验同源防开放重定向)/ 已登录但无权限 → 403」,且⭐ 403 与 404 要分清(403 显示「缺什么权限 + 找谁开通」,或用 404 掩盖存在性——要明确决策并写文档);⭐ 动态路由大多不值得(组件靠字符串映射、首屏白屏、404 与无权限难区分、类型与代码分割变复杂)——✅ 推荐「静态路由表 + 用同一份数据源生成菜单(权限过滤)」(⚠️ 记得摘掉「空分组」);菜单与路由必须同一份数据源(否则「菜单有但 403」或「能访问但没入口」);403 时拉一次最新用户信息(应对权限被后台改);⭐ 把「前端权限 = 体验优化」写进文档。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.12 路由工程化:权限矩阵与动态菜单

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫