- 全项目逐文件审查: 4 份审计报告(shared/core-business/management/new-modules) - 重写 004 架构影响地图: 图优先 + 模块依赖图 + 数据流 + 调用链 + 问题分级 - 更新 005 结构化数据: 新增 architectureOverview/moduleDependencyGraph/knownIssues/dbTables 节点 - 更新 006 功能清单: 143 项功能标注实现状态, P0 覆盖率 80%->92% - 更新 007 差距审计: v2->v3, P0 完成 69%->84%, 新增架构技术债章节 - 更新 001 项目概览: 6 角色/54 权限/26 模块/54 表 - 新增 docs/README.md 文档索引 - 归档 11 份过时文档(002x2/003/designx8) 标注 - 更新 work_log
30 KiB
⚠️ 已归档文档 本文档记录的是 2026-06 初的 RBAC 权限体系重构方案,描述的是修复前的安全隐患(如 exams/actions.ts 中的硬编码 getCurrentUser 存根)。 当前已由 004 架构影响地图 与 005 架构数据 取代——所有 Server Action 已接入
requirePermission()/requireAuth(),54 个权限点与 6 角色映射已落地。 保留用于历史参考,不再维护。
企业级权限体系重构方案
基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件
一、问题诊断
1.1 安全隐患
隐患 A:考试模块 Server Action 零鉴权
exams/actions.ts:721-723 中的 getCurrentUser() 是一个硬编码存根:
async function getCurrentUser() {
return { id: "user_teacher_math", role: "teacher" }
}
后果:任何已登录用户(包括 student/parent)都可以调用 deleteExamAction、updateExamAction、duplicateExamAction,删除或修改任意考试。攻击路径:
- 学生 A 登录系统,获取合法 session
- 直接调用
deleteExamAction,传入任意examId - Server Action 不校验调用者身份,直接执行
db.delete(exams).where(eq(exams.id, examId)) - 考试被删除
隐患 B:越权修改他人考试
即使修复了 getCurrentUser(),updateExamAction 和 deleteExamAction 仍不检查资源归属:
// updateExamAction — 任何 teacher 可修改任何考试
await db.update(exams).set(updateData).where(eq(exams.id, examId))
// 缺少: AND creatorId = currentUserId
教师 B 可以修改教师 A 创建的考试,只需知道 examId。
隐患 C:班级管理部分接口无鉴权
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 中多角色解析逻辑取优先级最高的一个角色,存入 JWT 的单一 role 字段。一个同时是 teacher + grade_head 的用户,在 JWT 中只保留 teacher,导致年级管理功能不可用。
问题 C:数据权限无统一抽象
homework/actions.ts 手动实现了"教师只能为自己班级的作业发布任务"的逻辑(30+ 行),classes/actions.ts 手动实现了"年级主任只能管理自己年级"的逻辑(20+ 行)。每个模块各自实现,无法复用。
1.3 可维护性问题
问题 A:鉴权逻辑与业务逻辑耦合
每个 Server Action 的前 10-30 行都是鉴权代码,与业务逻辑混杂,难以测试和复用。
问题 B:前端条件渲染依赖角色字符串
// 当前:硬编码角色判断
{role === "teacher" ? <TeacherView /> : <StudentView />}
// 无法支持:年级主任看到额外的管理入口
二、企业级权限模型设计
2.1 权限模型:RBAC + 数据权限
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ User │────→│ UserRole │←────│ Role │
│ │ M:N │ (多角色) │ │ (角色定义) │
└─────────────┘ └──────────────┘ └──────┬──────┘
│ M:N
┌──────┴──────┐
│ RolePermission│
└──────┬──────┘
│ N:1
┌──────┴──────┐
│ Permission │
│ (权限点) │
└─────────────┘
2.2 权限点定义
权限点采用 resource:action 命名规范:
// 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 角色-权限映射
// 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 层自动注入过滤条件:
// 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 数据库变更
新增一张表(最小变更,不修改现有表结构):
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:
// 追加到 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 类型
// 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
// 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() 服务端权限断言
// 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
// 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)
// 零鉴权,getCurrentUser() 返回硬编码值
export async function deleteExamAction(prevState, formData) {
const { examId } = parsed.data
await db.delete(exams).where(eq(exams.id, examId)) // 任何人可删任何考试
}
改造后
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 层数据权限过滤
// 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 改造
// 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 前端组件改造
改造前
// 硬编码角色判断
{role === "teacher" ? <TeacherView /> : <StudentView />}
改造后
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 天)
- 创建
src/shared/types/permissions.ts— 权限点定义 - 创建
src/shared/lib/permissions.ts— 角色-权限映射 - 创建
src/shared/lib/auth-guard.ts—requirePermission()/getAuthContext() - 创建
src/shared/hooks/use-permission.ts— 前端 Hook - 修改
src/next-auth.d.ts— Session/JWT 类型扩展 - 修改
src/auth.ts— JWT/Session callbacks 注入权限 - 新增
role_permissions数据库表 + seed 脚本
Phase 2:修复高危模块(2-3 天)
优先修复零鉴权和越权漏洞:
- exams/actions.ts — 替换
getCurrentUser()存根,所有 Action 加requirePermission() - classes/actions.ts — 无鉴权接口补全,统一使用
requirePermission() - homework/actions.ts — 用
requirePermission()替换ensureTeacher()/ensureStudent() - questions/actions.ts — 补全鉴权
- textbooks/actions.ts — 补全鉴权
Phase 3:数据权限集成(2-3 天)
- 各模块
data-access.ts接受DataScope参数 getExams()/getHomeworkAssignments()等查询函数自动过滤- 页面级 Server Component 传入
AuthContext
Phase 4:Middleware 升级(1 天)
proxy.ts从角色路由匹配 → 权限点路由匹配- API 路由增加权限检查
Phase 5:前端改造(2-3 天)
- 逐步替换
role === "xxx"为hasPermission() - 侧边栏导航从
NAV_CONFIG[role]→NAV_CONFIG.filter(item => hasPermission(item.permission)) - 条件渲染统一使用
usePermission()
五、考试模块改造对比
5.1 Server Actions 改造
改造前 — createExamAction
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
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
export async function deleteExamAction(prevState, formData) {
const { examId } = parsed.data
// 无任何鉴权!任何人可删任何考试!
await db.delete(exams).where(eq(exams.id, examId))
}
改造后 — deleteExamAction
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
export const getExams = cache(async (params: GetExamsParams) => {
// 任何调用者都能看到所有考试
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
})
})
改造后 — getExams
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
// 仅通过路由守卫保护,组件内无权限判断
<Button onClick={handleDelete}>删除考试</Button>
改造后 — exam-actions.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,无需改动业务代码