Files
2025-12-05 00:09:32 +08:00

20 KiB
Raw Permalink Blame History

权限系统原理详解

🎯 核心问题

如何实现"一次请求,全局共享"?

用户访问任何页面时:

  • 只调用一次权限API/rbac/user/routes
  • 所有子页面都能访问权限数据
  • 页面切换时不需要重新请求

📚 Remix 数据加载机制

工作流程

1. 用户访问 /prompts
        ↓
2. Remix 识别路由层级
   root.tsx (父)
     └─ prompts._index.tsx (子)
        ↓
3. 同时并行调用所有 loader
   ┌─────────────────────┐
   │ root.tsx loader     │ ← 获取用户信息、权限映射表
   │ (1 次 API 请求)     │
   └─────────────────────┘
   ┌─────────────────────┐
   │ prompts loader      │ ← 获取提示词列表
   │ (另 1 次 API 请求)   │
   └─────────────────────┘
        ↓
4. 所有数据在服务端收集完成
   {
     root: { permissionMap, userRole, ... },
     prompts: { templates, total, ... }
   }
        ↓
5. 一次性返回给浏览器
        ↓
6. React 渲染页面,所有组件都能访问这些数据

关键点

  1. root loader 只在首次加载时执行

    • 用户打开网站 → 执行 root loader
    • 权限数据被加载并缓存在 React Context 中
  2. 页面切换时不会重新执行 root loader

    • /prompts/documents
    • 不会重新调用 root loader
    • 只调用 documents._index.tsx 的 loader
    • root 的数据(包括权限映射表)仍然可用
  3. 手动触发重新加载

    • 用户刷新页面 → 重新执行所有 loader
    • 调用 revalidate() → 重新执行指定的 loader

🪝 useRouteLoaderData 的工作原理

什么是 useRouteLoaderData

这是 Remix 提供的 Hook,用于在任意组件中访问任意路由的 loader 数据。

// 在任何组件中使用
const rootData = useRouteLoaderData("root");
//                                    ↑
//                          路由ID(在 root.tsx 中定义)

数据存储位置

Remix 在内部维护了一个类似这样的数据结构:

// Remix 内部的数据存储(简化版)
const routeLoaderDataStore = {
  "root": {
    userRole: "admin",
    permissionMap: {
      "/prompts": ["prompt_template:list:read", ...],
      "/documents": ["document:list:read", ...]
    },
    frontendJWT: "eyJhbGci...",
    ENV: { ... }
  },
  "routes/prompts._index": {
    templates: [...],
    total: 27,
    currentPage: 1
  },
  "routes/documents._index": {
    documents: [...],
    total: 150
  }
};

每个路由的 loader 数据都存储在 Remix 的全局状态中!

访问机制

// app/hooks/usePermission.tsx

export function usePermission() {
  // 1️⃣ 通过路由ID访问 root loader 的数据
  const rootData = useRouteLoaderData("root");
  //    ↓
  //    这会从 Remix 的全局状态中读取:
  //    routeLoaderDataStore["root"]

  // 2️⃣ 提取权限映射表
  const permissionMap = rootData?.permissionMap || {};
  //    如果 root loader 执行成功,permissionMap 就是:
  //    { "/prompts": [...], "/documents": [...] }

  // 3️⃣ 获取当前路由路径
  const location = useLocation();
  const currentPath = location.pathname; // 如 "/prompts"

  // 4️⃣ 根据当前路径查询权限
  const currentPermissions = permissionMap[currentPath] || [];
  //    currentPermissions = permissionMap["/prompts"]
  //                       = ["prompt_template:list:read", ...]

  // 5️⃣ 提供权限检查方法
  const hasPermission = (key: string) => {
    return currentPermissions.includes(key);
  };

  return { hasPermission, currentPermissions, ... };
}

🔄 完整的数据流

让我用一个实际的例子演示整个流程:

场景:用户访问提示词管理页面

// ==========================================
// 第1步:用户在浏览器输入 http://localhost:5173/prompts
// ==========================================

// ==========================================
// 第2步:Remix 服务器识别路由
// ==========================================
// 路由层级:
// - root.tsx (id: "root")
//   └─ routes/prompts._index.tsx (id: "routes/prompts._index")

// ==========================================
// 第3步:并行执行所有 loader(服务端)
// ==========================================

// app/root.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  console.log("🔵 [Root Loader] 开始执行");

  const session = await getUserSession(request);
  const { userRole, frontendJWT } = session;

  // 📡 调用后端API获取用户路由和权限
  const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
  // 后端返回:
  // {
  //   success: true,
  //   data: [...菜单数据],
  //   permissionMap: {
  //     "/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"],
  //     "/documents": ["document:list:read", "document:create:write"]
  //   }
  // }

  console.log("🔵 [Root Loader] 权限映射表:", routesResult.permissionMap);

  return Response.json({
    userRole,
    frontendJWT,
    permissionMap: routesResult.permissionMap,  // ✅ 关键:传递权限映射表
    ENV: { ... }
  });
}

// app/routes/prompts._index.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  console.log("🟢 [Prompts Loader] 开始执行");

  const { getUserSession } = await import("~/api/login/auth.server");
  const { frontendJWT } = await getUserSession(request);

  // 📡 调用后端API获取提示词列表
  const result = await getPromptTemplates({ page: 1, pageSize: 10 }, frontendJWT);

  console.log("🟢 [Prompts Loader] 获取到", result.data?.templates.length, "条数据");

  return Response.json({
    templates: result.data?.templates || [],
    total: result.data?.total || 0
  });
}

// ==========================================
// 第4步:Remix 收集所有 loader 的数据
// ==========================================
const serverData = {
  "root": {
    userRole: "admin",
    frontendJWT: "eyJhbGci...",
    permissionMap: {
      "/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"],
      "/documents": ["document:list:read", "document:create:write"]
    },
    ENV: { ... }
  },
  "routes/prompts._index": {
    templates: [{ id: 1, name: "模板1" }, ...],
    total: 27,
    currentPage: 1
  }
};

// ==========================================
// 第5步:Remix 将数据嵌入HTML并返回给浏览器(SSR)
// ==========================================
// HTML 中会包含这样的 script 标签:
<script>
  window.__remixContext = {
    state: {
      loaderData: {
        "root": { userRole: "admin", permissionMap: {...}, ... },
        "routes/prompts._index": { templates: [...], total: 27 }
      }
    }
  };
</script>

// ==========================================
// 第6步:浏览器接收HTMLReact 开始 hydration(水合)
// ==========================================

// ==========================================
// 第7步:组件渲染,使用 Hook 访问数据
// ==========================================

// app/routes/prompts._index.tsx
export default function PromptsIndex() {
  // 🪝 使用自定义 Hook
  const { hasPermission, currentPermissions } = usePermission();
  //    ↓
  //    内部调用 useRouteLoaderData("root")
  //    ↓
  //    从 window.__remixContext.state.loaderData["root"] 中读取
  //    ↓
  //    rootData = {
  //      userRole: "admin",
  //      permissionMap: {
  //        "/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"]
  //      }
  //    }
  //    ↓
  //    location.pathname = "/prompts"
  //    ↓
  //    currentPermissions = permissionMap["/prompts"]
  //                       = ["prompt_template:list:read", "prompt_template:delete:delete"]

  // 检查权限
  const canCreate = hasPermission("prompt_template:create:write");
  //    ↓
  //    currentPermissions.includes("prompt_template:create:write")
  //    ↓
  //    false(因为权限列表中没有这个权限)

  const canDelete = hasPermission("prompt_template:delete:delete");
  //    ↓
  //    true(权限列表中有这个权限)

  console.log("🟡 [Prompts Page] 当前权限:", currentPermissions);
  console.log("🟡 [Prompts Page] 可以创建:", canCreate);  // false
  console.log("🟡 [Prompts Page] 可以删除:", canDelete);  // true

  return (
    <div>
      {/* 🔐 根据权限控制按钮显示 */}
      {canCreate && <Button>新增模板</Button>}  {/* ❌ 不显示 */}
      {canDelete && <Button>删除</Button>}      {/* ✅ 显示 */}
    </div>
  );
}

🚀 为什么是"全局共享"

关键点1root loader 只执行一次

用户首次访问网站
    
root.tsx loader 执行
    
权限映射表被加载到 Remix 全局状态
    
用户切换页面 /prompts  /documents
    
 root loader 不会重新执行
 permissionMap 仍然存在于全局状态中
    
新页面的组件可以通过 useRouteLoaderData("root") 访问

关键点2:所有页面都能访问

// 在 /prompts 页面
const { hasPermission } = usePermission();
// 读取 rootData.permissionMap["/prompts"]

// 切换到 /documents 页面
const { hasPermission } = usePermission();
// 读取 rootData.permissionMap["/documents"]

// ✅ permissionMap 数据始终在内存中
// ✅ 不需要重新请求 API

关键点3:何时重新加载?

只有这些情况会重新执行 root loader

  1. 用户刷新页面(F5

    浏览器发起新的HTTP请求
        ↓
    服务器重新执行所有 loader
        ↓
    权限数据重新加载
    
  2. 手动调用 revalidate

    import { useRevalidator } from "@remix-run/react";
    
    const revalidator = useRevalidator();
    
    // 权限变更后手动刷新
    revalidator.revalidate();  // 重新执行所有 loader
    
  3. 表单提交后自动重新验证

    // Remix 的 Form 组件提交后会自动 revalidate
    <Form method="post" action="/update-permissions">
      {/* 提交后会自动重新加载 loader 数据 */}
    </Form>
    

🎨 数据流可视化

┌─────────────────────────────────────────────────────────────┐
│                    浏览器首次访问                              │
└─────────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────┐
│                    Remix Server                              │
│                                                              │
│  ┌──────────────────┐         ┌──────────────────┐         │
│  │  root.tsx        │         │  prompts._index  │         │
│  │  loader()        │         │  loader()        │         │
│  │                  │         │                  │         │
│  │  📡 API Call     │         │  📡 API Call     │         │
│  │  ↓               │         │  ↓               │         │
│  │  GET /rbac/      │         │  GET /api/v3/    │         │
│  │  user/routes     │         │  prompt-         │         │
│  │                  │         │  templates       │         │
│  │  ✅ 返回权限     │         │  ✅ 返回列表     │         │
│  └──────────────────┘         └──────────────────┘         │
│                                                              │
└──────────────────────┬───────────────────────────────────────┘
                       ↓
        ┌──────────────────────────────────┐
        │   收集所有 loader 数据            │
        │                                   │
        │   {                               │
        │     "root": {                     │
        │       permissionMap: {...}        │
        │     },                            │
        │     "routes/prompts": {           │
        │       templates: [...]            │
        │     }                             │
        │   }                               │
        └──────────────────┬────────────────┘
                           ↓
        ┌──────────────────────────────────┐
        │   嵌入到HTML并返回                │
        │                                   │
        │   <script>                        │
        │     window.__remixContext = {     │
        │       state: {                    │
        │         loaderData: {...}         │
        │       }                           │
        │     }                             │
        │   </script>                       │
        └──────────────────┬────────────────┘
                           ↓
┌─────────────────────────────────────────────────────────────┐
│                    浏览器端                                   │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │            Remix 全局状态(内存)                      │   │
│  │                                                       │   │
│  │  loaderData = {                                       │   │
│  │    "root": {                                          │   │
│  │      permissionMap: {                                 │   │
│  │        "/prompts": ["list:read", "delete:delete"]    │   │
│  │      }                                                │   │
│  │    }                                                  │   │
│  │  }                                                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                          ↑                                   │
│                          │ useRouteLoaderData("root")        │
│                          │                                   │
│  ┌──────────────────────────────────────────────────────┐  │
│  │  PromptsIndex 组件                                     │  │
│  │                                                        │  │
│  │  const { hasPermission } = usePermission();           │  │
│  │  //         ↑                                          │  │
│  │  //         读取 root 的 permissionMap                │  │
│  │  //         查询当前路由的权限                        │  │
│  │                                                        │  │
│  │  const canCreate = hasPermission("...create...");     │  │
│  │  // false                                              │  │
│  │                                                        │  │
│  │  return (                                              │  │
│  │    {canCreate && <Button>新增</Button>}  ❌ 不渲染    │  │
│  │  );                                                    │  │
│  └──────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

💡 核心总结

usePermission Hook 的作用

export function usePermission() {
  // 1️⃣ 从 Remix 全局状态中读取 root loader 的数据
  const rootData = useRouteLoaderData("root");

  // 2️⃣ 提取权限映射表
  const permissionMap = rootData?.permissionMap || {};

  // 3️⃣ 获取当前路由路径
  const location = useLocation();
  const currentPath = location.pathname;

  // 4️⃣ 根据当前路径查询对应的权限列表
  const currentPermissions = permissionMap[currentPath] || [];

  // 5️⃣ 封装权限检查逻辑
  const hasPermission = (key: string) => {
    return currentPermissions.includes(key);
  };

  // 6️⃣ 提供便捷方法
  const canCreate = (module: string) => {
    return hasPermission(`${module}:create:write`);
  };

  return { hasPermission, canCreate, currentPermissions, ... };
}

Hook 的作用就是:

  • 📖 读取全局状态中的权限数据
  • 🎯 匹配当前路由对应的权限
  • 🔍 检查用户是否有指定权限
  • 🎁 封装成易用的方法

为什么是"一次请求,全局共享"?

步骤 说明
1️⃣ 一次请求 root loader 只在首次加载时调用 /rbac/user/routes
2️⃣ 全局存储 权限数据存储在 Remix 的全局状态中(内存)
3️⃣ 所有页面共享 任何组件都可以通过 useRouteLoaderData("root") 访问
4️⃣ 页面切换不重新请求 切换路由时,root loader 不会重新执行
5️⃣ 刷新才重新加载 只有刷新页面或手动 revalidate 才会重新请求

🔧 对比传统方案

传统方案(每次都请求)

// 每个页面都独立请求权限
export default function PromptsPage() {
  const [permissions, setPermissions] = useState([]);

  useEffect(() => {
    // 📡 每次进入页面都调用API
    fetch('/api/user/permissions')
      .then(res => res.json())
      .then(data => setPermissions(data));
  }, []);

  // 问题:
  // 1. 每个页面都要写一遍请求逻辑
  // 2. 页面切换时都会重新请求(浪费带宽)
  // 3. 请求期间页面可能闪烁(loading状态)
}

Remix 方案(一次请求,全局共享)

// root.tsx - 只请求一次
export async function loader() {
  const permissionMap = await getPermissions();  // 📡 只调用一次
  return { permissionMap };
}

// 任何页面 - 直接使用
export default function PromptsPage() {
  const { hasPermission } = usePermission();
  // ✅ 不需要请求,直接从全局状态读取
  // ✅ 页面切换时无需重新请求
  // ✅ 数据立即可用,无loading状态
}

📊 性能对比

场景:用户访问5个页面

传统方案

/prompts   → 📡 请求权限 (200ms)
/documents → 📡 请求权限 (200ms)
/rules     → 📡 请求权限 (200ms)
/settings  → 📡 请求权限 (200ms)
/reports   → 📡 请求权限 (200ms)
────────────────────────────────
总计:5次请求,1000ms

Remix 方案

首次加载  → 📡 请求权限 (200ms)  ← 只有这一次!
/prompts   → ✅ 从内存读取 (0ms)
/documents → ✅ 从内存读取 (0ms)
/rules     → ✅ 从内存读取 (0ms)
/settings  → ✅ 从内存读取 (0ms)
/reports   → ✅ 从内存读取 (0ms)
────────────────────────────────
总计:1次请求,200ms
性能提升:80%!

文档更新日期: 2025-11-27 作者: Claude Code