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,52 +1,18 @@
"use server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { db } from "@/shared/db";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } from "@/shared/db/schema";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema";
import { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { createId } from "@paralleldrive/cuid2";
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import { getQuestions, type GetQuestionsParams } from "./data-access";
import type { KnowledgePointOption } from "./types";
import { auth } from "@/auth";
const getSessionUserId = async (): Promise<string | null> => {
const session = await auth();
const userId = String(session?.user?.id ?? "").trim();
return userId.length > 0 ? userId : null;
};
async function ensureTeacher() {
const userId = await getSessionUserId();
if (!userId) {
const [fallback] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(roles.name, ["teacher", "admin"]))
.orderBy(asc(users.createdAt))
.limit(1);
if (!fallback) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return { id: fallback.id, role: fallback.role as "teacher" | "admin" };
}
const [row] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
.limit(1);
if (!row) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return { id: row.id, role: row.role as "teacher" | "admin" };
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
@@ -90,10 +56,10 @@ export async function createNestedQuestion(
formData: FormData | CreateQuestionInput
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const ctx = await requirePermission(Permissions.QUESTION_CREATE);
let rawInput: unknown = formData;
if (formData instanceof FormData) {
const jsonString = formData.get("json");
if (typeof jsonString === "string") {
@@ -116,7 +82,7 @@ export async function createNestedQuestion(
const input = validatedFields.data;
await db.transaction(async (tx) => {
await insertQuestionWithRelations(tx, input, user.id, null);
await insertQuestionWithRelations(tx, input, ctx.userId, null);
});
revalidatePath("/teacher/questions");
@@ -126,11 +92,14 @@ export async function createNestedQuestion(
message: "Question created successfully",
};
} catch (error) {
if (error instanceof Error) {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
if (e instanceof Error) {
return {
success: false,
message: error.message || "Database error occurred",
message: e.message || "Database error occurred",
};
}
@@ -154,8 +123,8 @@ export async function updateQuestionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const canEditAll = user.role === "admin";
const ctx = await requirePermission(Permissions.QUESTION_UPDATE);
const canEditAll = ctx.dataScope.type === "all";
const jsonString = formData.get("json");
if (typeof jsonString !== "string") {
@@ -182,7 +151,7 @@ export async function updateQuestionAction(
content: input.content,
updatedAt: new Date(),
})
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, user.id)));
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, ctx.userId)));
await tx
.delete(questionsToKnowledgePoints)
@@ -201,9 +170,12 @@ export async function updateQuestionAction(
revalidatePath("/teacher/questions");
return { success: true, message: "Question updated successfully" };
} catch (error) {
if (error instanceof Error) {
return { success: false, message: error.message };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
if (e instanceof Error) {
return { success: false, message: e.message };
}
return { success: false, message: "An unexpected error occurred" };
}
@@ -227,8 +199,8 @@ export async function deleteQuestionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const canDeleteAll = user.role === "admin";
const ctx = await requirePermission(Permissions.QUESTION_DELETE);
const canDeleteAll = ctx.dataScope.type === "all";
const questionId = formData.get("questionId");
if (typeof questionId !== "string") {
@@ -244,7 +216,7 @@ export async function deleteQuestionAction(
throw new Error("Question not found");
}
if (!canDeleteAll && q.authorId !== user.id) {
if (!canDeleteAll && q.authorId !== ctx.userId) {
throw new Error("Unauthorized");
}
@@ -254,21 +226,32 @@ export async function deleteQuestionAction(
revalidatePath("/teacher/questions");
return { success: true, message: "Question deleted successfully" };
} catch (error) {
if (error instanceof Error) {
return { success: false, message: error.message };
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
if (e instanceof Error) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to delete question" };
}
}
export async function getQuestionsAction(params: GetQuestionsParams) {
await ensureTeacher();
return await getQuestions(params);
try {
await requirePermission(Permissions.QUESTION_READ);
return await getQuestions(params);
} catch (e) {
if (e instanceof PermissionDeniedError) {
throw e;
}
throw e;
}
}
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
await ensureTeacher();
try {
await requirePermission(Permissions.QUESTION_READ);
const rows = await db
.select({
@@ -302,4 +285,10 @@ export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOp
subject: row.subject ?? null,
grade: row.grade ?? null,
}));
} catch (e) {
if (e instanceof PermissionDeniedError) {
throw e;
}
throw e;
}
}