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

@@ -1,53 +1,80 @@
import { NextResponse } from "next/server"
import type { NextAuthRequest } from "next-auth"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { auth } from "./auth"
function normalizeRole(value: unknown) {
const role = String(value ?? "").trim().toLowerCase()
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
// Route prefix → minimum required permission
const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin": "school:manage",
"/teacher": "exam:read",
"/student": "homework:submit",
"/parent": "exam:read",
"/management": "grade:manage",
}
function roleHome(role: string) {
if (role === "admin") return "/admin/dashboard"
if (role === "student") return "/student/dashboard"
if (role === "parent") return "/parent/dashboard"
return "/teacher/dashboard"
// API route prefix → required permission
const API_PERMISSIONS: Record<string, string> = {
"/api/ai/chat": "ai:chat",
}
export default auth((req: NextAuthRequest) => {
const { pathname } = req.nextUrl
const session = req.auth
function resolveDefaultPath(roles: string[]): string {
if (roles.includes("admin")) return "/admin/dashboard"
if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard"
if (roles.includes("teacher")) return "/teacher/dashboard"
if (roles.includes("student")) return "/student/dashboard"
if (roles.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}
if (!session?.user) {
const url = req.nextUrl.clone()
url.pathname = "/login"
url.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(url)
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip static assets and auth pages
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api/auth") ||
pathname === "/login" ||
pathname === "/register" ||
pathname === "/favicon.ico"
) {
return NextResponse.next()
}
const role = normalizeRole(session.user.role)
const token = await getToken({ req: request })
if (pathname.startsWith("/admin/") && role !== "admin") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
// Not authenticated → redirect to login
if (!token) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", request.url)
return NextResponse.redirect(loginUrl)
}
if (pathname.startsWith("/teacher/") && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
const permissions: string[] = (token.permissions as string[]) ?? []
const roles: string[] = (token.roles as string[]) ?? []
// Check API route permissions
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
break
}
}
if (pathname.startsWith("/student/") && role !== "student") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/parent/") && role !== "parent") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/management/") && role !== "admin" && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
// Check page route permissions
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()
})
}
export const config = {
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/management/:path*", "/settings/:path*", "/profile"],
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}