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,133 @@
import { auth } from "@/auth"
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
classSubjectTeachers,
grades,
} from "@/shared/db/schema"
import { eq, or } from "drizzle-orm"
export class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
}
/**
* Get the full authentication context for the current user.
* Throws if not authenticated.
*/
export async function getAuthContext(): Promise<AuthContext> {
const session = await auth()
const userId = session?.user?.id
if (!userId) throw new PermissionDeniedError("auth_required")
// Prefer session data (already resolved in JWT callback)
const roleNames = (session.user.roles ?? []) as string[]
const permissions = (session.user.permissions ?? []) as Permission[]
// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)
return { userId, roles: roleNames, permissions, dataScope }
}
/**
* Assert the current user has the specified permission.
* Returns AuthContext on success, throws PermissionDeniedError on failure.
*/
export async function requirePermission(permission: Permission): Promise<AuthContext> {
const ctx = await getAuthContext()
if (!ctx.permissions.includes(permission)) {
throw new PermissionDeniedError(permission)
}
return ctx
}
/**
* Check permission without throwing. Useful for conditional logic.
*/
export async function checkPermission(
permission: Permission
): Promise<{ allowed: boolean; ctx: AuthContext }> {
const ctx = await getAuthContext()
return { allowed: ctx.permissions.includes(permission), ctx }
}
/**
* Resolve the data scope for a user based on their roles.
* Queries the DB for resource ownership information.
*/
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
// Admin sees everything
if (roleNames.includes("admin")) {
return { type: "all" }
}
// Grade head / teaching head: can manage their grades
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
const managedGrades = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGrades.length > 0) {
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
}
}
// Teacher: can see their own classes
if (roleNames.includes("teacher")) {
// Classes where user is the homeroom teacher
const homeroomClasses = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, userId))
// Classes where user is a subject teacher
const subjectClasses = await db
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, userId))
const classIds = [
...new Set([
...homeroomClasses.map((c) => c.id),
...subjectClasses.map((c) => c.classId),
]),
]
const subjectIds = subjectClasses
.map((c) => c.subjectId)
.filter((s): s is string => s !== null)
return {
type: "class_taught",
classIds,
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
}
}
// Student: can see data from their enrolled classes
if (roleNames.includes("student")) {
return { type: "class_members" }
}
// Parent: can see their children's data
if (roleNames.includes("parent")) {
// TODO: implement parent-child relationship lookup
return { type: "children", childrenIds: [] }
}
// Fallback: only own data
return { type: "owned", userId }
}
/**
* Convenience: assert the user is authenticated (has any role).
* Returns AuthContext on success.
*/
export async function requireAuth(): Promise<AuthContext> {
return getAuthContext()
}

View File

@@ -0,0 +1,130 @@
import { Permissions, type Permission } from "@/shared/types/permissions"
// Role → Permission mapping
// New roles only need to add an entry here + seed the DB
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,
],
teaching_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_READ,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
],
}
/**
* Merge permissions from all roles (deduplicated)
*/
export function resolvePermissions(roleNames: string[]): Permission[] {
const set = new Set<Permission>()
for (const name of roleNames) {
const perms = ROLE_PERMISSIONS[name] ?? []
for (const p of perms) set.add(p)
}
return Array.from(set)
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest"
import { cn, formatDate } from "./utils"
describe("cn", () => {
it("should merge class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")
})
it("should handle conditional classes", () => {
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
})
it("should resolve tailwind conflicts", () => {
expect(cn("px-4", "px-6")).toBe("px-6")
})
it("should handle undefined and null", () => {
expect(cn("foo", undefined, null, "bar")).toBe("foo bar")
})
})
describe("formatDate", () => {
it("should format a date string with default zh-CN locale", () => {
const result = formatDate("2024-01-15")
expect(result).toContain("2024")
expect(result).toContain("1")
})
it("should format a date string with en-US locale", () => {
const result = formatDate("2024-01-15", "en-US")
expect(result).toContain("2024")
expect(result).toContain("Jan")
})
it("should format a Date object", () => {
const result = formatDate(new Date(2024, 5, 15))
expect(result).toContain("2024")
})
})

View File

@@ -5,10 +5,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString("en-US", {
export function formatDate(date: string | Date, locale: string = "zh-CN") {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
})
}).format(new Date(date))
}