"use server"; import { requirePermission } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; import type { ActionState } from "@/shared/types/action-state"; import { revalidatePath } from "next/cache"; import { getTranslations } from "next-intl/server"; import { getCurrentStudentUser } from "@/modules/users/data-access"; import { createTextbook, createChapter, updateChapterContent, deleteChapter, createKnowledgePoint, deleteKnowledgePoint, updateKnowledgePoint, updateTextbook, deleteTextbook, reorderChapters, verifyChapterBelongsToTextbook, verifyKnowledgePointBelongsToTextbook, getKnowledgePointsByChapterId, createPrerequisite, deletePrerequisite, getPrerequisiteEdgesForTextbook, } from "./data-access"; import { getKnowledgePointsWithRelations, getStudentKpMastery, } from "./data-access-graph"; import { CreateTextbookSchema, UpdateTextbookSchema, CreateChapterSchema, UpdateChapterContentSchema, CreateKnowledgePointSchema, UpdateKnowledgePointSchema, CreatePrerequisiteSchema, DeletePrerequisiteSchema, } from "./schema"; import { hasCycleAfterAddingEdge } from "./utils"; import type { GraphViewMode, KnowledgeGraphData, KnowledgePoint, MasteryInfo } from "./types"; import { handleActionError } from "@/shared/lib/action-utils"; const getStringValue = (formData: FormData, key: string): string => { const value = formData.get(key) return typeof value === "string" ? value : "" } export async function reorderChaptersAction( chapterId: string, newIndex: number, parentId: string | null, textbookId: string ): Promise { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_UPDATE); // P0-4 资源归属校验:防止越权操作其他教材的章节 const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId) if (!belongs) { return { success: false, message: t("chapterNotBelong") }; } await reorderChapters(chapterId, newIndex, parentId); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("chaptersReordered") }; } catch (e) { return handleActionError(e) } } export async function createTextbookAction( prevState: ActionState | null, formData: FormData ): Promise { const t = await getTranslations("textbooks.action"); const parsed = CreateTextbookSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), publisher: getStringValue(formData, "publisher"), }); if (!parsed.success) { return { success: false, message: t("fillRequired"), errors: parsed.error.flatten().fieldErrors, }; } try { await requirePermission(Permissions.TEXTBOOK_CREATE); await createTextbook(parsed.data); revalidatePath("/teacher/textbooks"); return { success: true, message: t("createSuccess"), }; } catch (e) { return handleActionError(e) } } export async function updateTextbookAction( textbookId: string, prevState: ActionState | null, formData: FormData ): Promise { const t = await getTranslations("textbooks.action"); const parsed = UpdateTextbookSchema.safeParse({ id: textbookId, title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), publisher: getStringValue(formData, "publisher"), }); if (!parsed.success) { return { success: false, message: t("fillRequired"), errors: parsed.error.flatten().fieldErrors, }; } try { await requirePermission(Permissions.TEXTBOOK_UPDATE); await updateTextbook(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("updateSuccess"), }; } catch (e) { return handleActionError(e) } } export async function deleteTextbookAction( textbookId: string ): Promise { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_DELETE); await deleteTextbook(textbookId); revalidatePath("/teacher/textbooks"); return { success: true, message: t("deleteSuccess"), }; } catch (e) { return handleActionError(e) } } export async function createChapterAction( textbookId: string, parentId: string | undefined, prevState: ActionState | null, formData: FormData ): Promise { const t = await getTranslations("textbooks.action"); const parsed = CreateChapterSchema.safeParse({ textbookId, title: getStringValue(formData, "title"), parentId, order: 0, }); if (!parsed.success) { return { success: false, message: t("titleRequired"), errors: parsed.error.flatten().fieldErrors, }; } try { await requirePermission(Permissions.TEXTBOOK_CREATE); await createChapter(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("chapterCreateSuccess") }; } catch (e) { return handleActionError(e) } } export async function updateChapterContentAction( chapterId: string, content: string, textbookId: string ): Promise { const t = await getTranslations("textbooks.action"); const parsed = UpdateChapterContentSchema.safeParse({ chapterId, content, }); if (!parsed.success) { return { success: false, message: t("invalidContent"), errors: parsed.error.flatten().fieldErrors, }; } try { await requirePermission(Permissions.TEXTBOOK_UPDATE); // P0-4 资源归属校验 const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId) if (!belongs) { return { success: false, message: t("chapterNotBelong") }; } await updateChapterContent(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("contentUpdateSuccess") }; } catch (e) { return handleActionError(e) } } export async function deleteChapterAction( chapterId: string, textbookId: string ): Promise { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_DELETE); // P0-4 资源归属校验 const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId) if (!belongs) { return { success: false, message: t("chapterNotBelong") }; } await deleteChapter(chapterId); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("chapterDeleteSuccess") }; } catch (e) { return handleActionError(e) } } export async function createKnowledgePointAction( chapterId: string, textbookId: string, prevState: ActionState | null, formData: FormData ): Promise { const t = await getTranslations("textbooks.action"); const parsed = CreateKnowledgePointSchema.safeParse({ name: getStringValue(formData, "name"), description: getStringValue(formData, "description") || undefined, anchorText: getStringValue(formData, "anchorText") || undefined, chapterId, }); if (!parsed.success) { return { success: false, message: t("nameRequired"), errors: parsed.error.flatten().fieldErrors, }; } try { await requirePermission(Permissions.TEXTBOOK_CREATE); // P0-4 资源归属校验:确保 chapter 属于该 textbook,防止跨教材越权创建知识点 const chapterBelongs = await verifyChapterBelongsToTextbook(chapterId, textbookId); if (!chapterBelongs) { return { success: false, message: t("chapterNotBelong") }; } await createKnowledgePoint(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("kpCreateSuccess") }; } catch (e) { return handleActionError(e) } } export async function deleteKnowledgePointAction( kpId: string, textbookId: string ): Promise { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_DELETE); // P0-4 资源归属校验 const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId) if (!belongs) { return { success: false, message: t("kpNotBelong") }; } await deleteKnowledgePoint(kpId); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("kpDeleteSuccess") }; } catch (e) { return handleActionError(e) } } export async function updateKnowledgePointAction( kpId: string, textbookId: string, prevState: ActionState | null, formData: FormData ): Promise { const t = await getTranslations("textbooks.action"); const parsed = UpdateKnowledgePointSchema.safeParse({ id: kpId, name: getStringValue(formData, "name"), description: getStringValue(formData, "description") || undefined, anchorText: getStringValue(formData, "anchorText") || undefined, }); if (!parsed.success) { return { success: false, message: t("nameRequired"), errors: parsed.error.flatten().fieldErrors, }; } try { await requirePermission(Permissions.TEXTBOOK_UPDATE); // P0-4 资源归属校验 const belongs = await verifyKnowledgePointBelongsToTextbook(kpId, textbookId) if (!belongs) { return { success: false, message: t("kpNotBelong") }; } await updateKnowledgePoint(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("kpUpdateSuccess") }; } catch (e) { return handleActionError(e) } } /** * P2-3 知识点懒加载:按章节 ID 获取知识点。 * * 用于详情页切换章节时按需加载,避免一次性拉取整本教材所有知识点。 * 需 TEXTBOOK_READ 权限。 */ export async function getKnowledgePointsByChapterAction( chapterId: string, textbookId: string ): Promise> { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_READ); // P0-4 资源归属校验:确保 chapter 属于该 textbook const belongs = await verifyChapterBelongsToTextbook(chapterId, textbookId); if (!belongs) { return { success: false, message: t("chapterNotBelong") }; } const knowledgePoints = await getKnowledgePointsByChapterId(chapterId); return { success: true, message: t("ok"), data: knowledgePoints }; } catch (e) { return handleActionError(e) } } // ===== 知识图谱 Actions ===== /** * 获取知识图谱数据。 * * - structure 模式:仅返回知识点+依赖+题目数 * - student-mastery 模式:附加当前学生掌握度 * - class-mastery 模式:附加班级平均掌握度(仅教师可用) */ export async function getKnowledgeGraphDataAction( textbookId: string, viewMode: GraphViewMode, ): Promise> { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_READ); const knowledgePointsData = await getKnowledgePointsWithRelations(textbookId); const masteryMap: Record = {}; if (viewMode === "student-mastery") { const student = await getCurrentStudentUser(); if (student) { const mastery = await getStudentKpMastery(student.id, textbookId); for (const [kpId, info] of mastery) { masteryMap[kpId] = info; } } } else if (viewMode === "class-mastery") { // 简化实现:暂不获取班级学生列表,返回空 masteryMap // 后续迭代可通过 classes 模块获取教师所带班级学生 ID, // 再从 data-access-graph 导入 getClassKpMastery 并调用 // getClassKpMastery(studentIds, textbookId) 计算班级平均掌握度 } return { success: true, message: t("ok"), data: { knowledgePoints: knowledgePointsData, masteryMap, viewMode }, }; } catch (e) { return handleActionError(e) } } /** * 声明前置依赖(含循环检测)。 */ export async function createPrerequisiteAction( formData: FormData, ): Promise { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_UPDATE); const knowledgePointId = getStringValue(formData, "knowledgePointId"); const prerequisiteKpId = getStringValue(formData, "prerequisiteKpId"); const textbookId = getStringValue(formData, "textbookId"); const parsed = CreatePrerequisiteSchema.safeParse({ knowledgePointId, prerequisiteKpId, }); if (!parsed.success) { return { success: false, message: t("invalidInput") }; } // 归属校验:两个知识点都必须属于当前教材,防止跨教材越权 const kpBelongs = await verifyKnowledgePointBelongsToTextbook( parsed.data.knowledgePointId, textbookId, ); if (!kpBelongs) { return { success: false, message: t("kpNotBelong") }; } const prereqBelongs = await verifyKnowledgePointBelongsToTextbook( parsed.data.prerequisiteKpId, textbookId, ); if (!prereqBelongs) { return { success: false, message: t("kpNotBelong") }; } // 循环检测 const existingEdges = await getPrerequisiteEdgesForTextbook(textbookId); if (hasCycleAfterAddingEdge( existingEdges, parsed.data.knowledgePointId, parsed.data.prerequisiteKpId, )) { return { success: false, message: t("cyclicDependency") }; } await createPrerequisite(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("prerequisiteCreated") }; } catch (e) { return handleActionError(e) } } /** * 删除前置依赖。 */ export async function deletePrerequisiteAction( formData: FormData, ): Promise { try { const t = await getTranslations("textbooks.action"); await requirePermission(Permissions.TEXTBOOK_UPDATE); const knowledgePointId = getStringValue(formData, "knowledgePointId"); const prerequisiteKpId = getStringValue(formData, "prerequisiteKpId"); const textbookId = getStringValue(formData, "textbookId"); const parsed = DeletePrerequisiteSchema.safeParse({ knowledgePointId, prerequisiteKpId, }); if (!parsed.success) { return { success: false, message: t("invalidInput") }; } // 归属校验:防止跨教材越权删除前置依赖 const kpBelongs = await verifyKnowledgePointBelongsToTextbook( parsed.data.knowledgePointId, textbookId, ); if (!kpBelongs) { return { success: false, message: t("kpNotBelong") }; } const prereqBelongs = await verifyKnowledgePointBelongsToTextbook( parsed.data.prerequisiteKpId, textbookId, ); if (!prereqBelongs) { return { success: false, message: t("kpNotBelong") }; } await deletePrerequisite(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("prerequisiteDeleted") }; } catch (e) { return handleActionError(e) } }