545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
"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)
|
||
}
|
||
}
|