Files
NextEdu/src/modules/lesson-preparation/data-access-versions.ts
SpecialX 20023e13fd feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff
- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
2026-07-03 10:25:21 +08:00

202 lines
5.6 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 { and, desc, eq, inArray, max } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema";
import { normalizeDocument } from "./data-access";
import type { LessonPlanDocument, LessonPlanVersion } from "./types";
// ---- 类型映射Drizzle 行 → LessonPlanVersionDate → ISO string----
function mapRowToVersion(row: {
id: string;
planId: string;
versionNo: number;
label: string | null;
content: unknown;
isAuto: boolean;
creatorId: string;
createdAt: Date;
}): LessonPlanVersion {
return {
id: row.id,
planId: row.planId,
versionNo: row.versionNo,
label: row.label,
content: normalizeDocument(row.content),
isAuto: row.isAuto,
creatorId: row.creatorId,
createdAt: row.createdAt.toISOString(),
};
}
export async function getLessonPlanVersions(
planId: string,
userId: string,
): Promise<LessonPlanVersion[]> {
// 校验归属
const plan = await db
.select({ id: lessonPlans.id })
.from(lessonPlans)
.where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
)
.limit(1);
if (plan.length === 0) return [];
const rows = await db
.select()
.from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, planId))
.orderBy(desc(lessonPlanVersions.versionNo));
return rows.map(mapRowToVersion);
}
export async function createLessonPlanVersion(input: {
planId: string;
content: LessonPlanDocument;
userId: string;
isAuto: boolean;
label?: string;
}): Promise<{ versionNo: number } | null> {
// P0 修复max(versionNo)+1 必须在事务内完成,避免并发产生重复版本号
return await db.transaction(async (tx) => {
// 校验 planId 归属
const plan = await tx
.select({ id: lessonPlans.id })
.from(lessonPlans)
.where(
and(eq(lessonPlans.id, input.planId), eq(lessonPlans.creatorId, input.userId)),
)
.limit(1);
if (plan.length === 0) return null;
const maxRow = await tx
.select({ maxNo: max(lessonPlanVersions.versionNo) })
.from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, input.planId));
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
await tx.insert(lessonPlanVersions).values({
id: createId(),
planId: input.planId,
versionNo: nextNo,
label: input.label ?? null,
content: input.content,
isAuto: input.isAuto,
creatorId: input.userId,
});
return { versionNo: nextNo };
});
}
export async function getVersionContent(
planId: string,
versionNo: number,
userId: string,
): Promise<LessonPlanDocument | null> {
// 校验归属
const plan = await db
.select({ id: lessonPlans.id })
.from(lessonPlans)
.where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
)
.limit(1);
if (plan.length === 0) return null;
const rows = await db
.select({ content: lessonPlanVersions.content })
.from(lessonPlanVersions)
.where(
and(
eq(lessonPlanVersions.planId, planId),
eq(lessonPlanVersions.versionNo, versionNo),
),
)
.limit(1);
if (rows.length === 0) return null;
return normalizeDocument(rows[0].content);
}
export async function revertToVersion(
planId: string,
versionNo: number,
userId: string,
// V3 修复:由 actions 层传入 i18n 翻译的回退标签,避免 data-access 硬编码中文
revertLabel: string,
): Promise<{ newVersionNo: number } | null> {
const content = await getVersionContent(planId, versionNo, userId);
if (!content) return null;
// P0 修复update 与 createLessonPlanVersion 必须在同一事务内,
// 避免回退后 content 已覆盖但版本记录未生成的不一致状态
return await db.transaction(async (tx) => {
await tx
.update(lessonPlans)
.set({ content, lastSavedAt: new Date() })
.where(
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
);
const maxRow = await tx
.select({ maxNo: max(lessonPlanVersions.versionNo) })
.from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, planId));
const newNo = (maxRow[0]?.maxNo ?? 0) + 1;
await tx.insert(lessonPlanVersions).values({
id: createId(),
planId,
versionNo: newNo,
label: revertLabel,
content,
isAuto: false,
creatorId: userId,
});
return { newVersionNo: newNo };
});
}
export async function pruneAutoVersions(
planId: string,
userId: string,
keep = 50,
): Promise<number> {
// 校验 planId 归属
const plan = await db
.select({ id: lessonPlans.id })
.from(lessonPlans)
.where(and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)))
.limit(1);
if (plan.length === 0) return 0;
const rows = await db
.select({
id: lessonPlanVersions.id,
isAuto: lessonPlanVersions.isAuto,
versionNo: lessonPlanVersions.versionNo,
})
.from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, planId))
.orderBy(desc(lessonPlanVersions.versionNo));
if (rows.length <= keep) return 0;
// 保留前 keep 条;超出部分只删 isAuto=true 的
const toDelete = rows.slice(keep).filter((r) => r.isAuto);
if (toDelete.length === 0) return 0;
await db
.delete(lessonPlanVersions)
.where(
and(
eq(lessonPlanVersions.planId, planId),
inArray(
lessonPlanVersions.id,
toDelete.map((r) => r.id),
),
),
);
return toDelete.length;
}