Files
NextEdu/src/modules/lesson-preparation/data-access.ts
SpecialX 97e59b95a1 refactor(lesson-preparation): V2 审计深度修复 — Server Actions i18n + 错误码模式 + 类型断言清零 + a11y 深度修复 + Tracker 埋点接入
V2-1: 12 个 Server Action 通过 getTranslations 翻译错误消息;Service/DataAccess 层抛出错误码异常(PublishServiceError/LessonPlanDataError),Actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息
V2-2: SYSTEM_TEMPLATES name/title 改为 i18n 键,createLessonPlan 接受 translateTitle 函数在服务端翻译后存储到 DB
V2-3: 8 处 as unknown as 断言替换为显式类型映射函数(mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate/mapRowToVersion)+ 类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope)
V2-4: MiniMap nodeColor 复用 lib/node-summary.ts 的 getNodeColor
V2-5: a11y 深度修复 — lesson-plan-filters/exercise-block/inline-question-editor 的 select 添加 label htmlFor 关联;exercise-block 题目列表改为 ul/li;node-editor 画布添加 role=application + 键盘导航配置
V2-6: Tracker 埋点接入 — 新增 useLessonPlanTrackerSafe hook,在 create/save/publish/revert/duplicate/archive 6 处调用 tracker.track

同步更新架构图 004 和 005 文档
2026-06-22 18:45:35 +08:00

412 lines
12 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.
import "server-only";
import { cache } from "react";
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import {
lessonPlans,
lessonPlanTemplates,
textbooks,
chapters,
subjects,
grades,
users,
} from "@/shared/db/schema";
import type { DataScope } from "@/shared/types/permissions";
import { SYSTEM_TEMPLATES } from "./constants";
import {
migrateV1ToV2,
normalizeDocument,
buildInitialContent,
} from "./lib/document-migration";
import type {
LessonPlan,
LessonPlanDocument,
LessonPlanListItem,
LessonPlanTemplate,
LessonPlanStatus,
TemplateType,
TemplateScope,
} from "./types";
// re-export 纯函数保持向后兼容
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
export class LessonPlanDataError extends Error {
constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") {
super(code);
this.name = "LessonPlanDataError";
}
}
// ---- 类型守卫:安全地将 DB string 收窄为联合类型 ----
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
function isLessonPlanStatus(v: string): v is LessonPlanStatus {
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
}
const TEMPLATE_TYPES = ["system", "personal"] as const;
function isTemplateType(v: string): v is TemplateType {
return (TEMPLATE_TYPES as readonly string[]).includes(v);
}
const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const;
function isTemplateScope(v: string): v is TemplateScope {
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
}
// ---- 类型映射Drizzle 行 → LessonPlanDate → ISO string----
function mapRowToLessonPlan(row: {
id: string;
title: string;
textbookId: string | null;
chapterId: string | null;
coursePlanItemId: string | null;
subjectId: string | null;
gradeId: string | null;
templateId: string | null;
templateName: string | null;
content: unknown;
status: string;
creatorId: string;
lastSavedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): LessonPlan {
return {
id: row.id,
title: row.title,
textbookId: row.textbookId,
chapterId: row.chapterId,
coursePlanItemId: row.coursePlanItemId,
subjectId: row.subjectId,
gradeId: row.gradeId,
templateId: row.templateId,
templateName: row.templateName,
content: normalizeDocument(row.content),
status: isLessonPlanStatus(row.status) ? row.status : "draft",
creatorId: row.creatorId,
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
// ---- 类型映射Drizzle 行 → LessonPlanListItem ----
function mapRowToListItem(row: {
id: string;
title: string;
textbookId: string | null;
chapterId: string | null;
coursePlanItemId: string | null;
subjectId: string | null;
gradeId: string | null;
templateId: string | null;
templateName: string | null;
content: unknown;
status: string;
creatorId: string;
lastSavedAt: Date | null;
createdAt: Date;
updatedAt: Date;
textbookTitle: string | null;
chapterTitle: string | null;
subjectName: string | null;
gradeName: string | null;
creatorName: string | null;
}): LessonPlanListItem {
return {
...mapRowToLessonPlan(row),
textbookTitle: row.textbookTitle,
chapterTitle: row.chapterTitle,
subjectName: row.subjectName,
gradeName: row.gradeName,
creatorName: row.creatorName,
};
}
// ---- 类型映射Drizzle 行 → LessonPlanTemplate ----
function mapRowToTemplate(row: {
id: string;
name: string;
type: string;
scope: string;
blocks: unknown;
creatorId: string | null;
createdAt: Date;
updatedAt: Date;
}): LessonPlanTemplate {
return {
id: row.id,
name: row.name,
type: isTemplateType(row.type) ? row.type : "personal",
scope: isTemplateScope(row.scope) ? row.scope : "custom",
// 从 unknown 转换为 TemplateBlockSkeleton[]DB JSON 字段)
blocks: row.blocks as LessonPlanTemplate["blocks"],
creatorId: row.creatorId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
// ---- DataScope → 查询条件 ----
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
switch (scope.type) {
case "all":
return [];
case "owned":
return [eq(lessonPlans.creatorId, userId)];
case "class_taught": {
// 教师:自己创建的 + published 且属于自己教授学科的
const own = eq(lessonPlans.creatorId, userId);
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
const subjectFilter =
scope.subjectIds && scope.subjectIds.length > 0
? inArray(lessonPlans.subjectId, scope.subjectIds)
: sql<boolean>`true`;
return [
or(
own,
and(publishedFilter, subjectFilter),
)!,
];
}
case "grade_managed": {
// 教研组长/年级主任:自己创建的 + published 且属于自己管理的年级
const own = eq(lessonPlans.creatorId, userId);
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
const gradeFilter =
scope.gradeIds.length > 0
? inArray(lessonPlans.gradeId, scope.gradeIds)
: sql<boolean>`false`;
return [
or(
own,
and(publishedFilter, gradeFilter),
)!,
];
}
case "class_members": {
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
return [publishedFilter];
}
case "children": {
// 家长:仅查看 published 课案(同学生)
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
return [publishedFilter];
}
}
}
// ---- 课案列表 ----
export const getLessonPlans = cache(
async (
params: {
query?: string;
textbookId?: string;
chapterId?: string;
subjectId?: string;
status?: string;
},
scope: DataScope,
userId: string,
): Promise<LessonPlanListItem[]> => {
const conditions: SQL[] = [
sql`${lessonPlans.status} != 'archived'`,
];
conditions.push(...buildScopeCondition(scope, userId));
if (params.query) {
conditions.push(like(lessonPlans.title, `%${params.query}%`));
}
if (params.textbookId)
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
if (params.chapterId)
conditions.push(eq(lessonPlans.chapterId, params.chapterId));
if (params.subjectId)
conditions.push(eq(lessonPlans.subjectId, params.subjectId));
if (params.status) conditions.push(eq(lessonPlans.status, params.status));
const rows = await db
.select({
id: lessonPlans.id,
title: lessonPlans.title,
textbookId: lessonPlans.textbookId,
chapterId: lessonPlans.chapterId,
coursePlanItemId: lessonPlans.coursePlanItemId,
subjectId: lessonPlans.subjectId,
gradeId: lessonPlans.gradeId,
templateId: lessonPlans.templateId,
templateName: lessonPlans.templateName,
content: lessonPlans.content,
status: lessonPlans.status,
creatorId: lessonPlans.creatorId,
lastSavedAt: lessonPlans.lastSavedAt,
createdAt: lessonPlans.createdAt,
updatedAt: lessonPlans.updatedAt,
textbookTitle: textbooks.title,
chapterTitle: chapters.title,
subjectName: subjects.name,
gradeName: grades.name,
creatorName: users.name,
})
.from(lessonPlans)
.leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id))
.leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id))
.leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id))
.leftJoin(grades, eq(lessonPlans.gradeId, grades.id))
.leftJoin(users, eq(lessonPlans.creatorId, users.id))
.where(and(...conditions))
.orderBy(desc(lessonPlans.updatedAt));
const items = rows.map(mapRowToListItem);
return items;
},
);
// ---- 单课案 ----
export const getLessonPlanById = cache(
async (id: string, userId: string): Promise<LessonPlan | null> => {
const rows = await db
.select()
.from(lessonPlans)
.where(eq(lessonPlans.id, id))
.limit(1);
if (rows.length === 0) return null;
const row = rows[0];
// 权限creator 可看 draft非 creator 仅 published
if (row.creatorId !== userId && row.status !== "published") return null;
return mapRowToLessonPlan(row);
},
);
// ---- 创建 ----
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
export async function createLessonPlan(input: {
title: string;
textbookId?: string;
chapterId?: string;
subjectId?: string;
gradeId?: string;
templateId: string;
creatorId: string;
translateTitle?: (key: string) => string;
}): Promise<{ planId: string }> {
const template = await getTemplateById(input.templateId);
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
const planId = createId();
// 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本
const blocks = input.translateTitle
? template.blocks.map((b) => ({
...b,
title: input.translateTitle!(b.title),
}))
: template.blocks;
const content = buildInitialContent(blocks);
await db.insert(lessonPlans).values({
id: planId,
title: input.title,
textbookId: input.textbookId ?? null,
chapterId: input.chapterId ?? null,
subjectId: input.subjectId ?? null,
gradeId: input.gradeId ?? null,
templateId: template.id,
templateName: template.name,
content,
status: "draft",
creatorId: input.creatorId,
lastSavedAt: new Date(),
});
return { planId };
}
// ---- 更新 content自动保存不生成版本----
export async function updateLessonPlanContent(
planId: string,
userId: string,
patch: { title?: string; content: LessonPlanDocument },
): Promise<void> {
await db
.update(lessonPlans)
.set({
...(patch.title ? { title: patch.title } : {}),
content: patch.content,
lastSavedAt: new Date(),
})
.where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
);
}
// ---- 软删除 ----
export async function softDeleteLessonPlan(
planId: string,
userId: string,
): Promise<void> {
await db
.update(lessonPlans)
.set({ status: "archived" })
.where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
);
}
// ---- 复制 ----
export async function duplicateLessonPlan(
planId: string,
userId: string,
): Promise<{ newPlanId: string }> {
const src = await getLessonPlanById(planId, userId);
if (!src) throw new LessonPlanDataError("NOT_FOUND");
const newId = createId();
await db.insert(lessonPlans).values({
id: newId,
title: `${src.title} - 副本`,
textbookId: src.textbookId,
chapterId: src.chapterId,
subjectId: src.subjectId,
gradeId: src.gradeId,
templateId: src.templateId,
templateName: src.templateName,
content: src.content,
status: "draft",
creatorId: userId,
lastSavedAt: new Date(),
});
return { newPlanId: newId };
}
// ---- 模板查询(内部)----
export async function getTemplateById(
templateId: string,
): Promise<LessonPlanTemplate | null> {
// 先查 system 固定模板
const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId);
if (sysDef) {
return {
id: sysDef.id,
name: sysDef.name,
type: "system",
scope: sysDef.scope,
blocks: sysDef.blocks,
creatorId: null,
createdAt: "",
updatedAt: "",
};
}
// 再查 DBpersonal 模板)
const rows = await db
.select()
.from(lessonPlanTemplates)
.where(eq(lessonPlanTemplates.id, templateId))
.limit(1);
return rows.length > 0 ? mapRowToTemplate(rows[0]) : null;
}