- 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
202 lines
5.6 KiB
TypeScript
202 lines
5.6 KiB
TypeScript
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 行 → LessonPlanVersion(Date → 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;
|
||
}
|