> ⚠️ **已归档文档**
> 本文档记录的是 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 <AdminSettingsView>` |
| `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" ? <TeacherView /> : <StudentView />}

// 无法支持：年级主任看到额外的管理入口
```

---

## 二、企业级权限模型设计

### 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<string, Permission[]> = {
  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<string>()
  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<AuthContext> {
  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<AuthContext> {
  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<DataScope> {
  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<ActionState> {
  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<string, string> = {
  "/admin":    "school:manage",
  "/teacher":  "exam:read",       // teacher 区域最低权限
  "/student":  "exam:read",       // student 区域最低权限
  "/parent":   "exam:read",       // parent 区域最低权限
}

// API 路由 → 所需权限映射
const API_PERMISSIONS: Record<string, string> = {
  "/api/ai/chat":         "ai:chat",
  "/api/onboarding":      "auth_required",  // 特殊标记：仅需登录
}

export async function middleware(request: NextRequest): Promise<NextResponse> {
  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" ? <TeacherView /> : <StudentView />}
```

#### 改造后

```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) && <GradingWidget />}
      {hasPermission(Permissions.EXAM_CREATE) && <CreateExamButton />}
      {hasPermission(Permissions.SCHOOL_MANAGE) && <AdminPanel />}
    </>
  )
}
```

---

## 四、渐进式重构路线图

### 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<ActionState> {
  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<ActionState> {
  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
// 仅通过路由守卫保护，组件内无权限判断
<Button onClick={handleDelete}>删除考试</Button>
```

#### 改造后 — 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) && (
        <Button onClick={handleEdit}>编辑</Button>
      )}
      {hasPermission(Permissions.EXAM_DELETE) && (
        <Button onClick={handleDelete}>删除</Button>
      )}
      {hasPermission(Permissions.EXAM_DUPLICATE) && (
        <Button onClick={handleDuplicate}>复制</Button>
      )}
    </>
  )
}
```

---

## 六、验收标准

- [ ] 所有 Server Action 都有 `requirePermission()` 调用
- [ ] `getCurrentUser()` 硬编码存根已移除
- [ ] 前端零 `role === "xxx"` 硬编码
- [ ] `exams/actions.ts` 的 CRUD 操作有资源归属校验
- [ ] `classes/actions.ts` 所有接口有鉴权
- [ ] `homework/actions.ts` 批改操作有资源归属校验
- [ ] Middleware 基于权限点而非角色字符串拦截
- [ ] 多角色用户可同时使用所有角色的功能
- [ ] `role_permissions` 表有 seed 数据
- [ ] 新增角色只需修改 `ROLE_PERMISSIONS` 映射 + seed，无需改动业务代码
