Files
NextEdu/src/modules/textbooks/actions.ts

545 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server";
import { requirePermission } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import type { ActionState } from "@/shared/types/action-state";
import { invalidateFor } from "@/shared/lib/cache";
import { getTranslations } from "next-intl/server";
import { getCurrentStudentUser } from "@/modules/users/data-access";
import { getGradeNameById } from "@/modules/school/data-access";
import {
createTextbook,
createChapter,
updateChapterContent,
deleteChapter,
createKnowledgePoint,
deleteKnowledgePoint,
updateKnowledgePoint,
updateTextbook,
deleteTextbook,
reorderChapters,
verifyChapterBelongsToTextbook,
verifyKnowledgePointBelongsToTextbook,
getKnowledgePointsByChapterId,
getTextbookById,
createPrerequisite,
deletePrerequisite,
getPrerequisiteEdgesForTextbook,
} from "./data-access";
import {
getKnowledgePointsWithRelations,
getStudentKpMastery,
getClassKpMastery,
} from "./data-access-graph";
import { getClassStudents } from "@/modules/classes/data-access-students";
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<ActionState> {
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);
await invalidateFor("textbooks.chapter.reorder", { textbookId });
return { success: true, message: t("chaptersReordered") };
} catch (e) {
return handleActionError(e)
}
}
export async function createTextbookAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
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);
await invalidateFor("textbooks.create");
return {
success: true,
message: t("createSuccess"),
};
} catch (e) {
return handleActionError(e)
}
}
export async function updateTextbookAction(
textbookId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
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);
await invalidateFor("textbooks.update", { id: textbookId });
return {
success: true,
message: t("updateSuccess"),
};
} catch (e) {
return handleActionError(e)
}
}
export async function deleteTextbookAction(
textbookId: string
): Promise<ActionState> {
try {
const t = await getTranslations("textbooks.action");
await requirePermission(Permissions.TEXTBOOK_DELETE);
await deleteTextbook(textbookId);
await invalidateFor("textbooks.delete");
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<ActionState> {
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);
await invalidateFor("textbooks.chapter.create", { textbookId });
return { success: true, message: t("chapterCreateSuccess") };
} catch (e) {
return handleActionError(e)
}
}
export async function updateChapterContentAction(
chapterId: string,
content: string,
textbookId: string
): Promise<ActionState> {
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);
await invalidateFor("textbooks.chapter.update", { textbookId });
return { success: true, message: t("contentUpdateSuccess") };
} catch (e) {
return handleActionError(e)
}
}
export async function deleteChapterAction(
chapterId: string,
textbookId: string
): Promise<ActionState> {
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);
await invalidateFor("textbooks.chapter.delete", { 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<ActionState> {
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);
await invalidateFor("textbooks.knowledgePoint.create", { textbookId });
return { success: true, message: t("kpCreateSuccess") };
} catch (e) {
return handleActionError(e)
}
}
export async function deleteKnowledgePointAction(
kpId: string,
textbookId: string
): Promise<ActionState> {
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);
await invalidateFor("textbooks.knowledgePoint.delete", { 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<ActionState> {
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);
await invalidateFor("textbooks.knowledgePoint.update", { 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<ActionState<KnowledgePoint[]>> {
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 模式:附加班级平均掌握度(仅教师可用)
*
* P0 安全修复:学生端按年级 scope 过滤,防止跨年级越权查看教材图谱数据。
* class-mastery 模式仅限教师使用getClassStudents 已通过 getAccessibleClassIdsForTeacher 过滤)。
*/
export async function getKnowledgeGraphDataAction(
textbookId: string,
viewMode: GraphViewMode,
): Promise<ActionState<KnowledgeGraphData>> {
try {
const t = await getTranslations("textbooks.action");
const ctx = await requirePermission(Permissions.TEXTBOOK_READ);
// P0 数据范围校验:学生只能查看本年级教材的图谱
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
const textbook = await getTextbookById(textbookId);
if (!textbook) {
return { success: false, message: t("notFound") };
}
// 学生/家长端:校验教材年级是否在允许范围内
if (textbook.grade) {
const allowedGradeIds = ctx.dataScope.gradeIds ?? [];
if (allowedGradeIds.length > 0) {
const allowedGradeNames = await Promise.all(
allowedGradeIds.map((gid) => getGradeNameById(gid))
);
if (!allowedGradeNames.includes(textbook.grade)) {
return { success: false, message: t("notFound") };
}
}
}
}
const knowledgePointsData = await getKnowledgePointsWithRelations(textbookId);
const masteryMap: Record<string, MasteryInfo> = {};
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;
}
}
// 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态
} else if (viewMode === "class-mastery") {
// class-mastery 模式仅限教师使用dataScope.type === "class_taught"
// getClassStudents 内部已通过 getAccessibleClassIdsForTeacher 过滤,仅返回当前教师可访问班级的学生
if (ctx.dataScope.type !== "class_taught" && ctx.dataScope.type !== "all") {
return { success: false, message: t("noClassMasteryPermission") };
}
const students = await getClassStudents({ status: "active" });
const studentIds = students.map((s) => s.id);
if (studentIds.length > 0) {
const mastery = await getClassKpMastery(studentIds, textbookId);
for (const [kpId, info] of mastery) {
masteryMap[kpId] = info;
}
}
}
return {
success: true,
message: t("ok"),
data: { knowledgePoints: knowledgePointsData, masteryMap, viewMode },
};
} catch (e) {
return handleActionError(e)
}
}
/**
* 声明前置依赖(含循环检测)。
*/
export async function createPrerequisiteAction(
formData: FormData,
): Promise<ActionState> {
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);
await invalidateFor("textbooks.prerequisite.create", { textbookId });
return { success: true, message: t("prerequisiteCreated") };
} catch (e) {
return handleActionError(e)
}
}
/**
* 删除前置依赖。
*/
export async function deletePrerequisiteAction(
formData: FormData,
): Promise<ActionState> {
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);
await invalidateFor("textbooks.prerequisite.delete", { textbookId });
return { success: true, message: t("prerequisiteDeleted") };
} catch (e) {
return handleActionError(e)
}
}