refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled

- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验
- UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内
- 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过)
- 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007)
- 项目规则: 架构图优先规则,改码必同步图
- 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫
- 无障碍: skip-link、aria-label、prefers-reduced-motion
- 性能: next/font优化、next/image、代码分割
This commit is contained in:
SpecialX
2026-06-16 23:38:33 +08:00
parent 99f116cb64
commit 125f7ec54c
75 changed files with 9480 additions and 3289 deletions

View File

@@ -0,0 +1,975 @@
# 企业级权限体系重构方案
> 基于源码逆向分析,覆盖 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?: any // 如 exams.creatorId
classIdField?: any // 如 classes.id
gradeIdField?: any // 如 grades.id
studentIdField?: any // 如 classEnrollments.studentId
}
): any[] {
const conditions: any[] = []
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() {
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) {
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) {
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 4Middleware 升级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) {
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) {
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无需改动业务代码