> ⚠️ **已归档文档** > 本文档记录的是 2026-06 初的 RBAC 权限体系重构方案,描述的是修复前的安全隐患(如 exams/actions.ts 中的硬编码 getCurrentUser 存根)。 > 当前已由 [004 架构影响地图](./004_architecture_impact_map.md) 与 [005 架构数据](./005_architecture_data.json) 取代——所有 Server Action 已接入 `requirePermission()`/`requireAuth()`,54 个权限点与 6 角色映射已落地。 > 保留用于历史参考,不再维护。 --- # 企业级权限体系重构方案 > 基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件 --- ## 一、问题诊断 ### 1.1 安全隐患 #### 隐患 A:考试模块 Server Action 零鉴权 [exams/actions.ts:721-723](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L721-L723) 中的 `getCurrentUser()` 是一个硬编码存根: ```typescript async function getCurrentUser() { return { id: "user_teacher_math", role: "teacher" } } ``` **后果**:任何已登录用户(包括 student/parent)都可以调用 `deleteExamAction`、`updateExamAction`、`duplicateExamAction`,删除或修改任意考试。攻击路径: 1. 学生 A 登录系统,获取合法 session 2. 直接调用 `deleteExamAction`,传入任意 `examId` 3. Server Action 不校验调用者身份,直接执行 `db.delete(exams).where(eq(exams.id, examId))` 4. 考试被删除 #### 隐患 B:越权修改他人考试 即使修复了 `getCurrentUser()`,`updateExamAction` 和 `deleteExamAction` 仍不检查资源归属: ```typescript // updateExamAction — 任何 teacher 可修改任何考试 await db.update(exams).set(updateData).where(eq(exams.id, examId)) // 缺少: AND creatorId = currentUserId ``` 教师 B 可以修改教师 A 创建的考试,只需知道 examId。 #### 隐患 C:班级管理部分接口无鉴权 [classes/actions.ts](file:///e:/Desktop/CICD/src/modules/classes/actions.ts) 中: - `enrollStudentByEmailAction` — 无任何 auth 检查 - `setStudentEnrollmentStatusAction` — 无任何 auth 检查 - `createClassScheduleItemAction` — 无任何 auth 检查 - `deleteClassScheduleItemAction` — 无任何 auth 检查 - `ensureClassInvitationCodeAction` — 无任何 auth 检查 - `regenerateClassInvitationCodeAction` — 无任何 auth 检查 任何已登录用户都可以操作任意班级的学生和课表。 #### 隐患 D:作业批改无资源归属校验 `gradeHomeworkSubmissionAction` 只调用 `ensureTeacher()`,确认调用者是教师身份,但不检查该教师是否有权批改此作业。任何教师可批改任何班级的作业。 ### 1.2 扩展性问题 #### 问题 A:角色硬编码散布全栈 前端 14 处 `role === "xxx"` 硬编码,后端各模块各自实现鉴权逻辑: | 位置 | 模式 | |------|------| | `proxy.ts` | `if (role === "admin")` | | `onboarding-gate.tsx` | `if (role === "admin")` / `role === "teacher"` / `role === "student"` | | `dashboard/page.tsx` | `if (role === "admin") redirect(...)` | | `settings/page.tsx` | `if (role === "admin") return ` | | `homework/actions.ts` | `ensureTeacher()` / `ensureStudent()` | | `classes/actions.ts` | `session.user.role !== "admin"` | 新增角色(如 `teaching_head`、`grade_head`)需要改动所有这些位置。 #### 问题 B:多角色用户被强制取一个 [auth.ts](file:///e:/Desktop/CICD/src/auth.ts) 中多角色解析逻辑取优先级最高的一个角色,存入 JWT 的单一 `role` 字段。一个同时是 `teacher` + `grade_head` 的用户,在 JWT 中只保留 `teacher`,导致年级管理功能不可用。 #### 问题 C:数据权限无统一抽象 `homework/actions.ts` 手动实现了"教师只能为自己班级的作业发布任务"的逻辑(30+ 行),`classes/actions.ts` 手动实现了"年级主任只能管理自己年级"的逻辑(20+ 行)。每个模块各自实现,无法复用。 ### 1.3 可维护性问题 #### 问题 A:鉴权逻辑与业务逻辑耦合 每个 Server Action 的前 10-30 行都是鉴权代码,与业务逻辑混杂,难以测试和复用。 #### 问题 B:前端条件渲染依赖角色字符串 ```tsx // 当前:硬编码角色判断 {role === "teacher" ? : } // 无法支持:年级主任看到额外的管理入口 ``` --- ## 二、企业级权限模型设计 ### 2.1 权限模型:RBAC + 数据权限 ``` ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ User │────→│ UserRole │←────│ Role │ │ │ M:N │ (多角色) │ │ (角色定义) │ └─────────────┘ └──────────────┘ └──────┬──────┘ │ M:N ┌──────┴──────┐ │ RolePermission│ └──────┬──────┘ │ N:1 ┌──────┴──────┐ │ Permission │ │ (权限点) │ └─────────────┘ ``` ### 2.2 权限点定义 权限点采用 `resource:action` 命名规范: ```typescript // src/shared/types/permissions.ts export const Permissions = { // 考试 EXAM_CREATE: "exam:create", EXAM_READ: "exam:read", EXAM_UPDATE: "exam:update", EXAM_DELETE: "exam:delete", EXAM_DUPLICATE: "exam:duplicate", EXAM_PUBLISH: "exam:publish", EXAM_AI_GENERATE: "exam:ai_generate", // 作业 HOMEWORK_CREATE: "homework:create", HOMEWORK_GRADE: "homework:grade", HOMEWORK_SUBMIT: "homework:submit", // 题库 QUESTION_CREATE: "question:create", QUESTION_READ: "question:read", QUESTION_UPDATE: "question:update", QUESTION_DELETE: "question:delete", // 教材 TEXTBOOK_CREATE: "textbook:create", TEXTBOOK_READ: "textbook:read", TEXTBOOK_UPDATE: "textbook:update", TEXTBOOK_DELETE: "textbook:delete", // 班级 CLASS_CREATE: "class:create", CLASS_READ: "class:read", CLASS_UPDATE: "class:update", CLASS_DELETE: "class:delete", CLASS_ENROLL: "class:enroll", CLASS_SCHEDULE: "class:schedule", // 学校管理 SCHOOL_MANAGE: "school:manage", GRADE_MANAGE: "grade:manage", USER_MANAGE: "user:manage", // AI AI_CHAT: "ai:chat", AI_CONFIGURE: "ai:configure", // 设置 SETTINGS_ADMIN: "settings:admin", } as const export type Permission = typeof Permissions[keyof typeof Permissions] ``` ### 2.3 角色-权限映射 ```typescript // src/shared/lib/permissions.ts import { Permissions, type Permission } from "@/shared/types/permissions" export const ROLE_PERMISSIONS: Record = { admin: [ Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH, Permissions.EXAM_AI_GENERATE, Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE, Permissions.QUESTION_CREATE, Permissions.QUESTION_READ, Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE, Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ, Permissions.TEXTBOOK_UPDATE, Permissions.TEXTBOOK_DELETE, Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE, Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE, Permissions.SCHOOL_MANAGE, Permissions.GRADE_MANAGE, Permissions.USER_MANAGE, Permissions.AI_CHAT, Permissions.AI_CONFIGURE, Permissions.SETTINGS_ADMIN, ], teacher: [ Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH, Permissions.EXAM_AI_GENERATE, Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE, Permissions.QUESTION_CREATE, Permissions.QUESTION_READ, Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE, Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ, Permissions.TEXTBOOK_UPDATE, Permissions.CLASS_READ, Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE, Permissions.AI_CHAT, ], student: [ Permissions.EXAM_READ, Permissions.HOMEWORK_SUBMIT, Permissions.QUESTION_READ, Permissions.TEXTBOOK_READ, Permissions.CLASS_READ, Permissions.AI_CHAT, ], parent: [ Permissions.EXAM_READ, Permissions.TEXTBOOK_READ, Permissions.CLASS_READ, ], // 可扩展:年级主任、教研组长等 grade_head: [ Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH, Permissions.EXAM_AI_GENERATE, Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE, Permissions.QUESTION_CREATE, Permissions.QUESTION_READ, Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE, Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ, Permissions.TEXTBOOK_UPDATE, Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE, Permissions.GRADE_MANAGE, Permissions.AI_CHAT, ], } ``` ### 2.4 数据权限策略 数据权限通过 `DataScope` 定义,在 data-access 层自动注入过滤条件: ```typescript // src/shared/types/permissions.ts export type DataScope = | { type: "all" } // admin: 全量 | { type: "owned" } // 仅自己创建的 | { type: "class_members" } // 所在班级的成员数据 | { type: "grade_managed"; gradeIds: string[] } // 管理的年级 | { type: "class_taught"; classIds: string[]; subjectIds?: string[] } // 教学的班级(可限定科目) | { type: "children"; childrenIds: string[] } // 家长:子女数据 export interface AuthContext { userId: string roles: string[] // 所有角色,不再只取一个 permissions: Permission[] // 合并所有角色的权限(去重) dataScope: DataScope // 数据权限范围 } ``` ### 2.5 数据库变更 **新增一张表**(最小变更,不修改现有表结构): ```sql CREATE TABLE role_permissions ( role_id VARCHAR(128) NOT NULL, permission VARCHAR(100) NOT NULL, PRIMARY KEY (role_id, permission), FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, INDEX idx_role_permissions_role (role_id) ); ``` Drizzle schema: ```typescript // 追加到 src/shared/db/schema.ts export const rolePermissions = mysqlTable("role_permissions", { roleId: varchar("role_id", { length: 128 }).notNull() .references(() => roles.id, { onDelete: "cascade" }), permission: varchar("permission", { length: 100 }).notNull(), }, (table) => ({ pk: primaryKey({ columns: [table.roleId, table.permission] }), roleIdIdx: index("role_permissions_role_idx").on(table.roleId), })) ``` **不修改现有表**。`ROLE_PERMISSIONS` 常量作为初始 seed 数据,运行时优先从数据库读取(支持动态调整),数据库无记录时 fallback 到常量。 --- ## 三、实现方案 ### 3.1 NextAuth Session 携带权限信息 #### 修改 JWT 和 Session 类型 ```typescript // src/next-auth.d.ts declare module "next-auth" { interface Session { user: DefaultSession["user"] & { id: string roles: string[] // 所有角色 permissions: string[] // 合并后的权限列表 } } } declare module "next-auth/jwt" { interface JWT { id: string roles: string[] permissions: string[] } } ``` #### 修改 auth.ts callbacks ```typescript // src/auth.ts — 关键改动 import { ROLE_PERMISSIONS } from "@/shared/lib/permissions" function resolvePermissions(roleNames: string[]): string[] { const set = new Set() for (const name of roleNames) { const perms = ROLE_PERMISSIONS[name] ?? [] for (const p of perms) set.add(p) } return Array.from(set) } export const authConfig = { callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id // 查询用户所有角色 const userRoles = await db .select({ name: roles.name }) .from(usersToRoles) .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .where(eq(usersToRoles.userId, user.id)) const roleNames = userRoles.map(r => r.name) token.roles = roleNames token.permissions = resolvePermissions(roleNames) } return token }, async session({ session, token }) { session.user.id = token.id session.user.roles = token.roles session.user.permissions = token.permissions return session }, }, } ``` ### 3.2 `requirePermission()` 服务端权限断言 ```typescript // src/shared/lib/auth-guard.ts import { auth } from "@/auth" import { ROLE_PERMISSIONS } from "@/shared/lib/permissions" import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions" import { db } from "@/shared/db" import { users, usersToRoles, roles, classes, classEnrollments, classSubjectTeachers, grades } from "@/shared/db/schema" import { eq, and, inArray } from "drizzle-orm" class PermissionDeniedError extends Error { constructor(permission: string) { super(`Permission denied: ${permission}`) this.name = "PermissionDeniedError" } } /** * 获取当前用户的完整认证上下文 */ export async function getAuthContext(): Promise { const session = await auth() const userId = session?.user?.id if (!userId) throw new PermissionDeniedError("auth_required") const roles = session.user.roles ?? [] const permissions = session.user.permissions ?? [] const dataScope = await resolveDataScope(userId, roles) return { userId, roles, permissions, dataScope } } /** * 断言当前用户拥有指定权限 */ export async function requirePermission(permission: Permission): Promise { const ctx = await getAuthContext() if (!ctx.permissions.includes(permission)) { throw new PermissionDeniedError(permission) } return ctx } /** * 断言当前用户拥有指定权限,并返回上下文(不抛异常版本) */ export async function checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }> { const ctx = await getAuthContext() return { allowed: ctx.permissions.includes(permission), ctx } } /** * 根据角色解析数据权限范围 */ async function resolveDataScope(userId: string, roleNames: string[]): Promise { if (roleNames.includes("admin")) { return { type: "all" } } // 年级主任 if (roleNames.includes("grade_head")) { const managedGrades = await db .select({ id: grades.id }) .from(grades) .where( and( eq(grades.gradeHeadId, userId), // 或 teachingHeadId ) ) if (managedGrades.length > 0) { return { type: "grade_managed", gradeIds: managedGrades.map(g => g.id) } } } // 教师 if (roleNames.includes("teacher")) { const taughtClasses = await db .select({ classId: classes.id, subjectId: classSubjectTeachers.subjectId }) .from(classes) .leftJoin(classSubjectTeachers, eq(classSubjectTeachers.classId, classes.id)) .where(eq(classes.teacherId, userId)) const classIds = [...new Set(taughtClasses.map(c => c.classId))] const subjectIds = taughtClasses .map(c => c.subjectId) .filter((s): s is string => s !== null) return { type: "class_taught", classIds, subjectIds: subjectIds.length > 0 ? subjectIds : undefined } } // 学生 if (roleNames.includes("student")) { return { type: "class_members" } } // 家长 if (roleNames.includes("parent")) { // TODO: 查询关联子女 return { type: "children", childrenIds: [] } } return { type: "owned" } } /** * 在 data-access 层使用:根据 DataScope 生成查询过滤条件 */ export function applyDataScope( scope: DataScope, options: { creatorIdField?: unknown // 如 exams.creatorId classIdField?: unknown // 如 classes.id gradeIdField?: unknown // 如 grades.id studentIdField?: unknown // 如 classEnrollments.studentId } ): unknown[] { const conditions: unknown[] = [] switch (scope.type) { case "all": break // 无过滤 case "owned": if (options.creatorIdField) { conditions.push(eq(options.creatorIdField, /* currentUserId */)) } break case "class_taught": if (options.classIdField && scope.classIds.length > 0) { conditions.push(inArray(options.classIdField, scope.classIds)) } break case "grade_managed": if (options.gradeIdField && scope.gradeIds.length > 0) { conditions.push(inArray(options.gradeIdField, scope.gradeIds)) } break case "class_members": // 需要子查询:学生所在班级 break } return conditions } export { PermissionDeniedError } ``` ### 3.3 `usePermission()` 前端 Hook ```typescript // src/shared/hooks/use-permission.ts "use client" import { useSession } from "next-auth/react" import type { Permission } from "@/shared/types/permissions" export function usePermission(): { permissions: Permission[] roles: string[] hasPermission: (permission: Permission) => boolean hasAnyPermission: (...perms: Permission[]) => boolean hasAllPermissions: (...perms: Permission[]) => boolean hasRole: (role: string) => boolean } { const { data: session } = useSession() const permissions = session?.user?.permissions ?? [] const roles = session?.user?.roles ?? [] const hasPermission = (permission: Permission): boolean => { return permissions.includes(permission) } const hasAnyPermission = (...perms: Permission[]): boolean => { return perms.some(p => permissions.includes(p)) } const hasAllPermissions = (...perms: Permission[]): boolean => { return perms.every(p => permissions.includes(p)) } const hasRole = (role: string): boolean => { return roles.includes(role) } return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole } } ``` ### 3.4 Server Action 集成模式 #### 改造前(exams/actions.ts) ```typescript // 零鉴权,getCurrentUser() 返回硬编码值 export async function deleteExamAction(prevState, formData) { const { examId } = parsed.data await db.delete(exams).where(eq(exams.id, examId)) // 任何人可删任何考试 } ``` #### 改造后 ```typescript import { requirePermission, getAuthContext, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" export async function deleteExamAction(prevState, formData): Promise { try { const ctx = await requirePermission(Permissions.EXAM_DELETE) const { examId } = parsed.data // 数据权限:非 admin 只能删自己的考试 if (ctx.dataScope.type !== "all") { const exam = await db.query.exams.findFirst({ where: eq(exams.id, examId), columns: { creatorId: true }, }) if (!exam || exam.creatorId !== ctx.userId) { return failState("You can only delete your own exams") } } await db.delete(exams).where(eq(exams.id, examId)) } catch (e) { if (e instanceof PermissionDeniedError) return failState(e.message) throw e } } ``` ### 3.5 data-access 层数据权限过滤 ```typescript // src/modules/exams/data-access.ts — 改造后 import { applyDataScope } from "@/shared/lib/auth-guard" import type { DataScope } from "@/shared/types/permissions" export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => { const conditions = [] // 原有筛选条件 if (params.q) { /* ... */ } if (params.status && params.status !== "all") { /* ... */ } // 数据权限过滤 const scopeConditions = applyDataScope(params.scope, { creatorIdField: exams.creatorId, gradeIdField: exams.gradeId, }) conditions.push(...scopeConditions) const data = await db.query.exams.findMany({ where: conditions.length ? and(...conditions) : undefined, // ... }) }) ``` ### 3.6 Middleware 改造 ```typescript // src/proxy.ts — 改造后 import { NextResponse } from "next/server" import type { NextRequest } from "next/server" import { getToken } from "next-auth/jwt" // 路由 → 所需权限映射 const ROUTE_PERMISSIONS: Record = { "/admin": "school:manage", "/teacher": "exam:read", // teacher 区域最低权限 "/student": "exam:read", // student 区域最低权限 "/parent": "exam:read", // parent 区域最低权限 } // API 路由 → 所需权限映射 const API_PERMISSIONS: Record = { "/api/ai/chat": "ai:chat", "/api/onboarding": "auth_required", // 特殊标记:仅需登录 } export async function middleware(request: NextRequest): Promise { const token = await getToken({ req: request }) if (!token) { const loginUrl = new URL("/login", request.url) loginUrl.searchParams.set("callbackUrl", request.url) return NextResponse.redirect(loginUrl) } const { pathname } = request.nextUrl const permissions: string[] = (token.permissions as string[]) ?? [] const roles: string[] = (token.roles as string[]) ?? [] // 检查 API 路由权限 for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (requiredPerm === "auth_required") break // 仅需登录 if (!permissions.includes(requiredPerm)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } break } } // 检查页面路由权限 for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!permissions.includes(requiredPerm)) { // 无权限 → 重定向到角色首页 const defaultPath = resolveDefaultPath(roles) return NextResponse.redirect(new URL(defaultPath, request.url)) } break } } return NextResponse.next() } function resolveDefaultPath(roles: string[]): string { if (roles.includes("admin")) return "/admin/dashboard" if (roles.includes("teacher")) return "/teacher/dashboard" if (roles.includes("student")) return "/student/dashboard" if (roles.includes("parent")) return "/parent/dashboard" return "/dashboard" } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], } ``` ### 3.7 前端组件改造 #### 改造前 ```tsx // 硬编码角色判断 {role === "teacher" ? : } ``` #### 改造后 ```tsx import { usePermission } from "@/shared/hooks/use-permission" import { Permissions } from "@/shared/types/permissions" function DashboardPage() { const { hasPermission, hasRole } = usePermission() return ( <> {hasPermission(Permissions.HOMEWORK_GRADE) && } {hasPermission(Permissions.EXAM_CREATE) && } {hasPermission(Permissions.SCHOOL_MANAGE) && } ) } ``` --- ## 四、渐进式重构路线图 ### Phase 1:基础设施(1-2 天) 1. 创建 `src/shared/types/permissions.ts` — 权限点定义 2. 创建 `src/shared/lib/permissions.ts` — 角色-权限映射 3. 创建 `src/shared/lib/auth-guard.ts` — `requirePermission()` / `getAuthContext()` 4. 创建 `src/shared/hooks/use-permission.ts` — 前端 Hook 5. 修改 `src/next-auth.d.ts` — Session/JWT 类型扩展 6. 修改 `src/auth.ts` — JWT/Session callbacks 注入权限 7. 新增 `role_permissions` 数据库表 + seed 脚本 ### Phase 2:修复高危模块(2-3 天) 优先修复零鉴权和越权漏洞: 1. **exams/actions.ts** — 替换 `getCurrentUser()` 存根,所有 Action 加 `requirePermission()` 2. **classes/actions.ts** — 无鉴权接口补全,统一使用 `requirePermission()` 3. **homework/actions.ts** — 用 `requirePermission()` 替换 `ensureTeacher()`/`ensureStudent()` 4. **questions/actions.ts** — 补全鉴权 5. **textbooks/actions.ts** — 补全鉴权 ### Phase 3:数据权限集成(2-3 天) 1. 各模块 `data-access.ts` 接受 `DataScope` 参数 2. `getExams()` / `getHomeworkAssignments()` 等查询函数自动过滤 3. 页面级 Server Component 传入 `AuthContext` ### Phase 4:Middleware 升级(1 天) 1. `proxy.ts` 从角色路由匹配 → 权限点路由匹配 2. API 路由增加权限检查 ### Phase 5:前端改造(2-3 天) 1. 逐步替换 `role === "xxx"` 为 `hasPermission()` 2. 侧边栏导航从 `NAV_CONFIG[role]` → `NAV_CONFIG.filter(item => hasPermission(item.permission))` 3. 条件渲染统一使用 `usePermission()` --- ## 五、考试模块改造对比 ### 5.1 Server Actions 改造 #### 改造前 — createExamAction ```typescript export async function createExamAction(prevState, formData) { // ... 解析表单 ... try { const user = await getCurrentUser() // 硬编码存根! await persistExamDraft({ examId: context.examId, creatorId: user?.id ?? "user_teacher_math", // 硬编码 fallback! // ... }) } catch (error) { return failState("Database error") } } ``` #### 改造后 — createExamAction ```typescript export async function createExamAction(prevState, formData): Promise { let ctx: AuthContext try { ctx = await requirePermission(Permissions.EXAM_CREATE) } catch (e) { if (e instanceof PermissionDeniedError) return failState(e.message) throw e } // ... 解析表单 ... try { await persistExamDraft({ examId: context.examId, creatorId: ctx.userId, // 真实用户 ID // ... }) } catch (error) { return failState("Database error") } } ``` #### 改造前 — deleteExamAction ```typescript export async function deleteExamAction(prevState, formData) { const { examId } = parsed.data // 无任何鉴权!任何人可删任何考试! await db.delete(exams).where(eq(exams.id, examId)) } ``` #### 改造后 — deleteExamAction ```typescript export async function deleteExamAction(prevState, formData): Promise { let ctx: AuthContext try { ctx = await requirePermission(Permissions.EXAM_DELETE) } catch (e) { if (e instanceof PermissionDeniedError) return failState(e.message) throw e } const { examId } = parsed.data // 数据权限:非 admin 只能删自己创建的考试 if (ctx.dataScope.type !== "all") { const exam = await db.query.exams.findFirst({ where: eq(exams.id, examId), columns: { creatorId: true }, }) if (!exam) return failState("Exam not found") if (exam.creatorId !== ctx.userId) { return failState("You can only delete your own exams") } } await db.delete(exams).where(eq(exams.id, examId)) revalidatePath("/teacher/exams/all") return successState(examId, "Exam deleted") } ``` ### 5.2 Data-Access 层改造 #### 改造前 — getExams ```typescript export const getExams = cache(async (params: GetExamsParams) => { // 任何调用者都能看到所有考试 const data = await db.query.exams.findMany({ where: conditions.length ? and(...conditions) : undefined, }) }) ``` #### 改造后 — getExams ```typescript export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => { const conditions = [] // 原有筛选 if (params.q) conditions.push(or(like(exams.title, search), like(exams.description, search))) if (params.status && params.status !== "all") conditions.push(eq(exams.status, params.status)) // 数据权限自动过滤 switch (params.scope.type) { case "all": break case "owned": conditions.push(eq(exams.creatorId, /* userId from caller */)) break case "class_taught": { // 教师能看到自己班级关联的年级的考试 if (params.scope.classIds.length > 0) { // 通过 class → grade 关联查询 const teacherGradeIds = await db .selectDistinct({ gradeId: classes.gradeId }) .from(classes) .where(inArray(classes.id, params.scope.classIds)) if (teacherGradeIds.length > 0) { conditions.push(inArray(exams.gradeId, teacherGradeIds.map(g => g.gradeId))) } } break } case "grade_managed": if (params.scope.gradeIds.length > 0) { conditions.push(inArray(exams.gradeId, params.scope.gradeIds)) } break case "class_members": { // 学生看到自己年级的已发布考试 // 需要子查询获取学生所在班级的年级 break } } const data = await db.query.exams.findMany({ where: conditions.length ? and(...conditions) : undefined, }) }) ``` ### 5.3 前端改造 #### 改造前 — exam-actions.tsx ```tsx // 仅通过路由守卫保护,组件内无权限判断 ``` #### 改造后 — exam-actions.tsx ```tsx import { usePermission } from "@/shared/hooks/use-permission" import { Permissions } from "@/shared/types/permissions" function ExamActions({ examId }) { const { hasPermission } = usePermission() return ( <> {hasPermission(Permissions.EXAM_UPDATE) && ( )} {hasPermission(Permissions.EXAM_DELETE) && ( )} {hasPermission(Permissions.EXAM_DUPLICATE) && ( )} ) } ``` --- ## 六、验收标准 - [ ] 所有 Server Action 都有 `requirePermission()` 调用 - [ ] `getCurrentUser()` 硬编码存根已移除 - [ ] 前端零 `role === "xxx"` 硬编码 - [ ] `exams/actions.ts` 的 CRUD 操作有资源归属校验 - [ ] `classes/actions.ts` 所有接口有鉴权 - [ ] `homework/actions.ts` 批改操作有资源归属校验 - [ ] Middleware 基于权限点而非角色字符串拦截 - [ ] 多角色用户可同时使用所有角色的功能 - [ ] `role_permissions` 表有 seed 数据 - [ ] 新增角色只需修改 `ROLE_PERMISSIONS` 映射 + seed,无需改动业务代码