Files
NextEdu/src/modules/textbooks/actions.ts
SpecialX ec87cd9efa fix(textbooks): 规范核查修复 — 安全漏洞+功能缺失+i18n+类型安全
安全:createPrerequisiteAction 补充 prerequisiteKpId 归属校验;deletePrerequisiteAction 补充双知识点归属校验,防止跨教材越权。

功能:实现图谱添加/删除前置依赖(Dialog + Select 选择知识点 + 调用 Server Action + 自动刷新图谱),替换原 no-op 回调。

i18n:修复 8 处硬编码英文字符串(textbook-reader/chapter-sidebar-list/textbook-card/textbook-form-dialog/textbook-settings-dialog/create-chapter-dialog/teacher-textbook-reader),新增 saveFailed/createFailed/updateFailed/deleteFailed/questionCreatorDefaultContent 等 key。

类型安全:graph-prerequisite-edge.tsx 使用 GraphEdgeData 类型经 unknown 安全转换,替代裸 as 断言。

规范:analytics.tsx 移动 use client 指令到文件第一行;同步架构文档 005 JSON 类型定义(GraphNodeData/GraphEdgeData/MasteryLevel)。

验证:教材模块 lint 零错误、tsc 零错误、193 个单元测试全部通过。
2026-06-23 00:30:14 +08:00

508 lines
16 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 { 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<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);
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<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);
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<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);
revalidatePath(`/teacher/textbooks/${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);
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<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);
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<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);
revalidatePath(`/teacher/textbooks/${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);
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<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);
revalidatePath(`/teacher/textbooks/${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);
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<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);
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<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 模式:附加班级平均掌握度(仅教师可用)
*/
export async function getKnowledgeGraphDataAction(
textbookId: string,
viewMode: GraphViewMode,
): Promise<ActionState<KnowledgeGraphData>> {
try {
const t = await getTranslations("textbooks.action");
await requirePermission(Permissions.TEXTBOOK_READ);
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;
}
}
} 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<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);
revalidatePath(`/teacher/textbooks/${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);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: t("prerequisiteDeleted") };
} catch (e) {
return handleActionError(e)
}
}