diff --git a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx
index e535ad1..84440b7 100644
--- a/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx
+++ b/src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx
@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import type { LessonPlanNode } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
+import { notify } from "@/shared/lib/notify";
export interface ContextMenuState {
visible: boolean;
@@ -56,11 +57,10 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
const copyNode = async (nodeId: string) => {
const newId = duplicateNode(nodeId);
- const { toast } = await import("sonner");
if (newId) {
- toast.success(t("v4.contextMenu.copied"));
+ notify.success(t("v4.contextMenu.copied"));
} else {
- toast.error(t("v4.contextMenu.copyFailed"));
+ notify.error(t("v4.contextMenu.copyFailed"));
}
};
@@ -69,7 +69,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
if (onAiAction) {
onAiAction(action, nodeId);
} else {
- void import("sonner").then(({ toast }) => toast.info(t("v4.contextMenu.aiComingSoon")));
+ notify.info(t("v4.contextMenu.aiComingSoon"));
}
};
diff --git a/src/modules/lesson-preparation/components/schedule-dialog.tsx b/src/modules/lesson-preparation/components/schedule-dialog.tsx
index 0cda6dd..62605f1 100644
--- a/src/modules/lesson-preparation/components/schedule-dialog.tsx
+++ b/src/modules/lesson-preparation/components/schedule-dialog.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { X, Calendar, Trash2, Plus } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
-import { toast } from "sonner";
+import { notify } from "@/shared/lib/notify";
import {
getLessonPlanSchedulesAction,
createLessonPlanScheduleAction,
@@ -57,7 +57,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
}
} catch (e) {
console.error("[ScheduleDialog] load failed", e);
- toast.error(t("error.getOne"));
+ notify.error(t("error.getOne"));
} finally {
setLoading(false);
}
@@ -95,7 +95,7 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
durationMin,
});
if (res.success) {
- toast.success(t("schedule.addSuccess"));
+ notify.success(t("schedule.addSuccess"));
void loadSchedules();
onScheduled?.();
} else {
@@ -113,15 +113,15 @@ export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props)
try {
const res = await deleteLessonPlanScheduleAction(id);
if (res.success) {
- toast.success(t("schedule.deleteSuccess"));
+ notify.success(t("schedule.deleteSuccess"));
void loadSchedules();
onScheduled?.();
} else {
- toast.error(res.message ?? t("error.save"));
+ notify.error(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[ScheduleDialog] delete failed", e);
- toast.error(t("error.save"));
+ notify.error(t("error.save"));
}
}
diff --git a/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx b/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx
index 39e54d4..79433e3 100644
--- a/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx
+++ b/src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx
@@ -4,7 +4,8 @@ import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { TreeNodeRow } from "./tree-node-row";
-import type { Block, InteractionBlockData, LessonPlanNode, TextbookContentNode } from "../../types";
+import type { LessonPlanNode, TextbookContentNode } from "../../types";
+import { isInteractionBlockData } from "../../lib/type-guards";
export function StructureTree() {
const t = useTranslations("lessonPreparation");
@@ -69,7 +70,7 @@ export function StructureTree() {
{/* 正文节点(始终在最上) */}
{textbookNode && (
)}
@@ -82,8 +83,8 @@ export function StructureTree() {
// 师生交互节点:子节点为对话轮次
let childItems: { id: string; label: string; kind: string }[] = [];
let hasChildren = false;
- if (node.type === "interaction") {
- const data = node.data as InteractionBlockData;
+ if (node.type === "interaction" && isInteractionBlockData(node.data)) {
+ const data = node.data;
hasChildren = (data.turns?.length ?? 0) > 0;
childItems = (data.turns ?? []).map((turn, idx) => ({
id: turn.id,
diff --git a/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx b/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
index b0804dd..e31b960 100644
--- a/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
+++ b/src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
@@ -1,11 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
-import type { Block } from "../../types";
+import type { AnyLessonPlanNode } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
- node: Block;
+ node: AnyLessonPlanNode;
/** 是否展开到正文 */
isExpanded: boolean;
/** 是否有子节点(如师生交互的对话轮次) */
diff --git a/src/modules/lesson-preparation/components/version-history-drawer.tsx b/src/modules/lesson-preparation/components/version-history-drawer.tsx
index b6b73b2..b5d2a82 100644
--- a/src/modules/lesson-preparation/components/version-history-drawer.tsx
+++ b/src/modules/lesson-preparation/components/version-history-drawer.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
-import { toast } from "sonner";
+import { notify } from "@/shared/lib/notify";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
@@ -85,15 +85,15 @@ export function VersionHistoryDrawer({
const res = await service.revertLessonPlanVersion({ planId, versionNo });
if (res.success) {
tracker.track("lesson_plan.revert", { planId, versionNo });
- toast.success(t("version.revertSuccess", { versionNo }));
+ notify.success(t("version.revertSuccess", { versionNo }));
onReverted();
onClose();
} else {
- toast.error(res.message ?? t("error.revert"));
+ notify.error(res.message ?? t("error.revert"));
}
} catch (e) {
console.error("[VersionHistoryDrawer] revert failed", e);
- toast.error(t("error.revert"));
+ notify.error(t("error.revert"));
}
}
diff --git a/src/modules/lesson-preparation/config/block-registry.tsx b/src/modules/lesson-preparation/config/block-registry.tsx
index 35e48a8..9c9bc49 100644
--- a/src/modules/lesson-preparation/config/block-registry.tsx
+++ b/src/modules/lesson-preparation/config/block-registry.tsx
@@ -31,7 +31,6 @@ import { HomeworkBlock } from "../components/blocks/homework-block";
import { BlackboardBlock } from "../components/blocks/blackboard-block";
import { ReflectionBlock } from "../components/blocks/reflection-block";
import { InteractionBlock } from "../components/blocks/interaction-block";
-import type { InteractionBlockData } from "../types";
/**
* Block 注册表:配置驱动渲染。
@@ -208,7 +207,7 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
rest.onUpdate(d as InteractionBlockData)}
+ onUpdate={(d) => rest.onUpdate(d)}
/>
);
}
diff --git a/src/modules/lesson-preparation/data-access-ai-evaluation.ts b/src/modules/lesson-preparation/data-access-ai-evaluation.ts
index 60d714a..75c842d 100644
--- a/src/modules/lesson-preparation/data-access-ai-evaluation.ts
+++ b/src/modules/lesson-preparation/data-access-ai-evaluation.ts
@@ -11,6 +11,7 @@ import { lessonPlanAiEvaluations } from "@/shared/db/schema";
import { eq, desc } from "drizzle-orm";
import { cacheFn } from "@/shared/lib/cache";
import type { LessonPlanDocument } from "./types";
+import { isStringArray } from "./lib/type-guards";
/** 单次评估结果 */
export interface AiEvaluation {
@@ -34,6 +35,19 @@ export interface DimensionScores {
assessment: number;
}
+/** 类型守卫:判断 unknown 是否为合法的 DimensionScores(用于 DB JSON 字段安全收窄) */
+function isDimensionScores(v: unknown): v is DimensionScores {
+ if (typeof v !== "object" || v === null) return false;
+ const d = v as Record;
+ return (
+ typeof d.clarity === "number" &&
+ typeof d.alignment === "number" &&
+ typeof d.engagement === "number" &&
+ typeof d.differentiation === "number" &&
+ typeof d.assessment === "number"
+ );
+}
+
/**
* 查询某课案的所有 AI 评估记录
*/
@@ -41,7 +55,17 @@ export async function getEvaluationsByPlanIdRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanAiEvaluations.id,
+ planId: lessonPlanAiEvaluations.planId,
+ versionNo: lessonPlanAiEvaluations.versionNo,
+ overallScore: lessonPlanAiEvaluations.overallScore,
+ dimensionScores: lessonPlanAiEvaluations.dimensionScores,
+ suggestions: lessonPlanAiEvaluations.suggestions,
+ recommendedStandardIds: lessonPlanAiEvaluations.recommendedStandardIds,
+ evaluatedBy: lessonPlanAiEvaluations.evaluatedBy,
+ createdAt: lessonPlanAiEvaluations.createdAt,
+ })
.from(lessonPlanAiEvaluations)
.where(eq(lessonPlanAiEvaluations.planId, planId))
.orderBy(desc(lessonPlanAiEvaluations.createdAt));
@@ -60,7 +84,17 @@ export async function getLatestEvaluationRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanAiEvaluations.id,
+ planId: lessonPlanAiEvaluations.planId,
+ versionNo: lessonPlanAiEvaluations.versionNo,
+ overallScore: lessonPlanAiEvaluations.overallScore,
+ dimensionScores: lessonPlanAiEvaluations.dimensionScores,
+ suggestions: lessonPlanAiEvaluations.suggestions,
+ recommendedStandardIds: lessonPlanAiEvaluations.recommendedStandardIds,
+ evaluatedBy: lessonPlanAiEvaluations.evaluatedBy,
+ createdAt: lessonPlanAiEvaluations.createdAt,
+ })
.from(lessonPlanAiEvaluations)
.where(eq(lessonPlanAiEvaluations.planId, planId))
.orderBy(desc(lessonPlanAiEvaluations.createdAt))
@@ -180,15 +214,21 @@ export function evaluateDocument(
function mapRowToEvaluation(
row: typeof lessonPlanAiEvaluations.$inferSelect,
): AiEvaluation {
+ // DB JSON 字段为 unknown,用类型守卫安全收窄
+ const dimensionScores = isDimensionScores(row.dimensionScores)
+ ? row.dimensionScores
+ : null;
+ const recommendedStandardIds = isStringArray(row.recommendedStandardIds)
+ ? row.recommendedStandardIds
+ : null;
return {
id: row.id,
planId: row.planId,
versionNo: row.versionNo,
overallScore: row.overallScore,
- dimensionScores: (row.dimensionScores as DimensionScores) ?? null,
+ dimensionScores,
suggestions: row.suggestions,
- recommendedStandardIds:
- (row.recommendedStandardIds as string[] | null) ?? null,
+ recommendedStandardIds,
evaluatedBy: row.evaluatedBy,
createdAt: row.createdAt,
};
diff --git a/src/modules/lesson-preparation/data-access-analytics.ts b/src/modules/lesson-preparation/data-access-analytics.ts
index c1b94b3..d5a1cb5 100644
--- a/src/modules/lesson-preparation/data-access-analytics.ts
+++ b/src/modules/lesson-preparation/data-access-analytics.ts
@@ -16,7 +16,6 @@ import {
lessonPlanStandards,
} from "@/shared/db/schema";
import { and, eq, gte, lte, desc, sql, count } from "drizzle-orm";
-import type { LessonPlanStatus } from "./types";
/** 教师备课投入数据点 */
export interface TeacherInvestmentDataPoint {
@@ -61,7 +60,16 @@ export async function getTeacherInvestmentRaw(
if (teacherId) conditions.push(eq(lessonPlanAnalyticsDaily.teacherId, teacherId));
const rows = await db
- .select()
+ .select({
+ snapshotDate: lessonPlanAnalyticsDaily.snapshotDate,
+ teacherId: lessonPlanAnalyticsDaily.teacherId,
+ newPlansCount: lessonPlanAnalyticsDaily.newPlansCount,
+ editDurationMin: lessonPlanAnalyticsDaily.editDurationMin,
+ savedVersionsCount: lessonPlanAnalyticsDaily.savedVersionsCount,
+ submittedCount: lessonPlanAnalyticsDaily.submittedCount,
+ publishedCount: lessonPlanAnalyticsDaily.publishedCount,
+ standardsLinked: lessonPlanAnalyticsDaily.standardsLinked,
+ })
.from(lessonPlanAnalyticsDaily)
.where(and(...conditions))
.orderBy(desc(lessonPlanAnalyticsDaily.snapshotDate));
@@ -96,7 +104,7 @@ export async function getTemplateUsageStatsRaw(): Promise ({
- templateId: r.templateId!,
+ templateId: r.templateId ?? "",
templateName: r.templateName ?? "(unknown)",
usageCount: r.usageCount,
}));
@@ -175,11 +183,11 @@ export async function getGlobalLessonPlanStatsRaw(): Promise<{
const publishedRows = await db
.select({ count: count() })
.from(lessonPlans)
- .where(eq(lessonPlans.status, "published" as LessonPlanStatus));
+ .where(eq(lessonPlans.status, "published"));
const submittedRows = await db
.select({ count: count() })
.from(lessonPlans)
- .where(eq(lessonPlans.status, "submitted" as LessonPlanStatus));
+ .where(eq(lessonPlans.status, "submitted"));
const standardsRows = await db
.select({ count: count() })
.from(lessonPlanStandards);
@@ -208,7 +216,15 @@ export async function upsertDailyAnalytics(
): Promise {
// 简化实现:先尝试更新,不存在则插入
const existing = await db
- .select()
+ .select({
+ id: lessonPlanAnalyticsDaily.id,
+ newPlansCount: lessonPlanAnalyticsDaily.newPlansCount,
+ editDurationMin: lessonPlanAnalyticsDaily.editDurationMin,
+ savedVersionsCount: lessonPlanAnalyticsDaily.savedVersionsCount,
+ submittedCount: lessonPlanAnalyticsDaily.submittedCount,
+ publishedCount: lessonPlanAnalyticsDaily.publishedCount,
+ standardsLinked: lessonPlanAnalyticsDaily.standardsLinked,
+ })
.from(lessonPlanAnalyticsDaily)
.where(
and(
diff --git a/src/modules/lesson-preparation/data-access-attachments.ts b/src/modules/lesson-preparation/data-access-attachments.ts
index dcc908b..3480f0b 100644
--- a/src/modules/lesson-preparation/data-access-attachments.ts
+++ b/src/modules/lesson-preparation/data-access-attachments.ts
@@ -28,7 +28,16 @@ export async function getAttachmentsByPlanIdRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanAttachments.id,
+ planId: lessonPlanAttachments.planId,
+ blockId: lessonPlanAttachments.blockId,
+ fileId: lessonPlanAttachments.fileId,
+ displayName: lessonPlanAttachments.displayName,
+ attachmentType: lessonPlanAttachments.attachmentType,
+ uploadedBy: lessonPlanAttachments.uploadedBy,
+ createdAt: lessonPlanAttachments.createdAt,
+ })
.from(lessonPlanAttachments)
.where(eq(lessonPlanAttachments.planId, planId))
.orderBy(desc(lessonPlanAttachments.createdAt));
@@ -46,7 +55,16 @@ export async function getAttachmentsByBlockIdRaw(
blockId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanAttachments.id,
+ planId: lessonPlanAttachments.planId,
+ blockId: lessonPlanAttachments.blockId,
+ fileId: lessonPlanAttachments.fileId,
+ displayName: lessonPlanAttachments.displayName,
+ attachmentType: lessonPlanAttachments.attachmentType,
+ uploadedBy: lessonPlanAttachments.uploadedBy,
+ createdAt: lessonPlanAttachments.createdAt,
+ })
.from(lessonPlanAttachments)
.where(
and(
@@ -120,7 +138,16 @@ export async function getAttachmentByIdRaw(
id: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanAttachments.id,
+ planId: lessonPlanAttachments.planId,
+ blockId: lessonPlanAttachments.blockId,
+ fileId: lessonPlanAttachments.fileId,
+ displayName: lessonPlanAttachments.displayName,
+ attachmentType: lessonPlanAttachments.attachmentType,
+ uploadedBy: lessonPlanAttachments.uploadedBy,
+ createdAt: lessonPlanAttachments.createdAt,
+ })
.from(lessonPlanAttachments)
.where(eq(lessonPlanAttachments.id, id))
.limit(1);
diff --git a/src/modules/lesson-preparation/data-access-calendar.ts b/src/modules/lesson-preparation/data-access-calendar.ts
index 2bf808f..a620117 100644
--- a/src/modules/lesson-preparation/data-access-calendar.ts
+++ b/src/modules/lesson-preparation/data-access-calendar.ts
@@ -10,6 +10,11 @@ import { db } from "@/shared/db";
import { lessonPlans, lessonPlanVersions, lessonPlanReviewRecords } from "@/shared/db/schema";
import { and, eq, gte, lte, asc } from "drizzle-orm";
import type { LessonPlanStatus } from "./types";
+// G1-026 去重:toLessonPlanStatus 已提取至 lib/status-mappers
+import { toLessonPlanStatus } from "./lib/status-mappers";
+
+/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
+const DEFAULT_LIMIT = 1000;
/** 日历事件 */
export interface LessonPlanCalendarEvent {
@@ -51,7 +56,8 @@ export async function getCalendarEventsRaw(
lte(lessonPlans.updatedAt, endDate),
),
)
- .orderBy(asc(lessonPlans.updatedAt));
+ .orderBy(asc(lessonPlans.updatedAt))
+ .limit(DEFAULT_LIMIT);
for (const p of planRows) {
// 创建事件
@@ -60,7 +66,7 @@ export async function getCalendarEventsRaw(
id: `${p.id}-created`,
planId: p.id,
title: p.title,
- status: p.status as LessonPlanStatus,
+ status: toLessonPlanStatus(p.status),
eventType: "created",
occurredAt: p.createdAt,
creatorId: p.creatorId,
@@ -72,19 +78,19 @@ export async function getCalendarEventsRaw(
id: `${p.id}-updated`,
planId: p.id,
title: p.title,
- status: p.status as LessonPlanStatus,
+ status: toLessonPlanStatus(p.status),
eventType: "updated",
occurredAt: p.updatedAt,
creatorId: p.creatorId,
});
}
// 提交/发布事件
- if (p.status === "submitted" as LessonPlanStatus || p.status === "published" as LessonPlanStatus) {
+ if (p.status === "submitted" || p.status === "published") {
events.push({
id: `${p.id}-${p.status}`,
planId: p.id,
title: p.title,
- status: p.status as LessonPlanStatus,
+ status: toLessonPlanStatus(p.status),
eventType: p.status === "submitted" ? "submitted" : "published",
occurredAt: p.updatedAt,
creatorId: p.creatorId,
@@ -111,14 +117,15 @@ export async function getCalendarEventsRaw(
lte(lessonPlanVersions.createdAt, endDate),
),
)
- .orderBy(asc(lessonPlanVersions.createdAt));
+ .orderBy(asc(lessonPlanVersions.createdAt))
+ .limit(DEFAULT_LIMIT);
for (const v of versionRows) {
events.push({
id: v.id,
planId: v.planId,
title: v.title,
- status: "draft" as LessonPlanStatus,
+ status: "draft",
eventType: "version_saved",
occurredAt: v.createdAt,
creatorId: v.creatorId,
@@ -144,14 +151,15 @@ export async function getCalendarEventsRaw(
gte(lessonPlanReviewRecords.createdAt, startDate),
lte(lessonPlanReviewRecords.createdAt, endDate),
),
- );
+ )
+ .limit(DEFAULT_LIMIT);
for (const r of reviewRows) {
events.push({
id: r.id,
planId: r.planId,
title: r.title,
- status: "approved" as LessonPlanStatus,
+ status: "approved",
eventType: "reviewed",
occurredAt: r.createdAt,
creatorId: r.reviewerId,
@@ -172,7 +180,7 @@ export function groupEventsByDate(
): Map {
const map = new Map();
for (const e of events) {
- const dateKey = e.occurredAt.toISOString().split("T")[0]!;
+ const dateKey = e.occurredAt.toISOString().split("T")[0] ?? "";
const list = map.get(dateKey) ?? [];
list.push(e);
map.set(dateKey, list);
diff --git a/src/modules/lesson-preparation/data-access-comments.ts b/src/modules/lesson-preparation/data-access-comments.ts
index 7554461..92e03c8 100644
--- a/src/modules/lesson-preparation/data-access-comments.ts
+++ b/src/modules/lesson-preparation/data-access-comments.ts
@@ -8,7 +8,7 @@ import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
import { lessonPlanComments } from "@/shared/db/schema";
-import { and, eq, asc, isNull } from "drizzle-orm";
+import { and, eq, asc, inArray, isNull } from "drizzle-orm";
/** 评论记录 */
export interface LessonPlanComment {
@@ -30,7 +30,17 @@ export async function getCommentsByPlanIdRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanComments.id,
+ planId: lessonPlanComments.planId,
+ blockId: lessonPlanComments.blockId,
+ content: lessonPlanComments.content,
+ authorId: lessonPlanComments.authorId,
+ resolved: lessonPlanComments.resolved,
+ parentCommentId: lessonPlanComments.parentCommentId,
+ createdAt: lessonPlanComments.createdAt,
+ updatedAt: lessonPlanComments.updatedAt,
+ })
.from(lessonPlanComments)
.where(eq(lessonPlanComments.planId, planId))
.orderBy(asc(lessonPlanComments.createdAt));
@@ -48,7 +58,17 @@ export async function getCommentsByBlockIdRaw(
blockId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanComments.id,
+ planId: lessonPlanComments.planId,
+ blockId: lessonPlanComments.blockId,
+ content: lessonPlanComments.content,
+ authorId: lessonPlanComments.authorId,
+ resolved: lessonPlanComments.resolved,
+ parentCommentId: lessonPlanComments.parentCommentId,
+ createdAt: lessonPlanComments.createdAt,
+ updatedAt: lessonPlanComments.updatedAt,
+ })
.from(lessonPlanComments)
.where(
and(
@@ -115,7 +135,9 @@ export async function toggleCommentResolved(
.where(eq(lessonPlanComments.id, commentId))
.limit(1);
if (rows.length === 0) return;
- const newResolved = !rows[0]!.resolved;
+ const row = rows[0];
+ if (!row) return;
+ const newResolved = !row.resolved;
await db
.update(lessonPlanComments)
.set({ resolved: newResolved, updatedAt: new Date() })
@@ -124,19 +146,33 @@ export async function toggleCommentResolved(
/**
* 删除评论(递归删除子回复)
+ *
+ * 使用 BFS 逐层 `inArray` 查询收集所有后代 ID,再单条 `inArray` 批量删除,
+ * 替代原先每节点逐条 SELECT + DELETE 的递归 N+1 模式。
*/
export async function deleteComment(commentId: string): Promise {
- // 先删除所有子回复
- const children = await db
- .select({ id: lessonPlanComments.id })
- .from(lessonPlanComments)
- .where(eq(lessonPlanComments.parentCommentId, commentId));
- for (const child of children) {
- await deleteComment(child.id);
+ const visited = new Set([commentId])
+ let currentLevel: string[] = [commentId]
+
+ while (currentLevel.length > 0) {
+ const children = await db
+ .select({ id: lessonPlanComments.id })
+ .from(lessonPlanComments)
+ .where(inArray(lessonPlanComments.parentCommentId, currentLevel));
+
+ const nextLevel: string[] = [];
+ for (const child of children) {
+ if (!visited.has(child.id)) {
+ visited.add(child.id);
+ nextLevel.push(child.id);
+ }
+ }
+ currentLevel = nextLevel;
}
+
await db
.delete(lessonPlanComments)
- .where(eq(lessonPlanComments.id, commentId));
+ .where(inArray(lessonPlanComments.id, Array.from(visited)));
}
/**
diff --git a/src/modules/lesson-preparation/data-access-formative.ts b/src/modules/lesson-preparation/data-access-formative.ts
index bd6d345..18e0c8a 100644
--- a/src/modules/lesson-preparation/data-access-formative.ts
+++ b/src/modules/lesson-preparation/data-access-formative.ts
@@ -16,6 +16,9 @@ import { eq, desc, asc } from "drizzle-orm";
import type { FormativeInteractionType } from "./lib/type-guards";
import { isFormativeInteractionType } from "./lib/type-guards";
+/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
+const DEFAULT_LIMIT = 1000;
+
/** 互动组件项 */
export interface FormativeItem {
id: string;
@@ -48,7 +51,17 @@ export async function getFormativeItemsByPlanIdRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanFormativeItems.id,
+ planId: lessonPlanFormativeItems.planId,
+ blockId: lessonPlanFormativeItems.blockId,
+ interactionType: lessonPlanFormativeItems.interactionType,
+ payload: lessonPlanFormativeItems.payload,
+ instantFeedback: lessonPlanFormativeItems.instantFeedback,
+ orderIndex: lessonPlanFormativeItems.orderIndex,
+ createdAt: lessonPlanFormativeItems.createdAt,
+ updatedAt: lessonPlanFormativeItems.updatedAt,
+ })
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.planId, planId))
.orderBy(asc(lessonPlanFormativeItems.orderIndex));
@@ -65,11 +78,22 @@ export async function getFormativeItemByIdRaw(
id: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanFormativeItems.id,
+ planId: lessonPlanFormativeItems.planId,
+ blockId: lessonPlanFormativeItems.blockId,
+ interactionType: lessonPlanFormativeItems.interactionType,
+ payload: lessonPlanFormativeItems.payload,
+ instantFeedback: lessonPlanFormativeItems.instantFeedback,
+ orderIndex: lessonPlanFormativeItems.orderIndex,
+ createdAt: lessonPlanFormativeItems.createdAt,
+ updatedAt: lessonPlanFormativeItems.updatedAt,
+ })
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.id, id))
.limit(1);
- return rows.length === 0 ? null : mapRowToItem(rows[0]!);
+ const firstRow = rows[0];
+ return firstRow ? mapRowToItem(firstRow) : null;
}
export const getFormativeItemById = cacheFn(getFormativeItemByIdRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "formative", "by-id"] });
@@ -166,7 +190,16 @@ export async function getResponsesByItemIdRaw(
itemId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanFormativeResponses.id,
+ itemId: lessonPlanFormativeResponses.itemId,
+ studentId: lessonPlanFormativeResponses.studentId,
+ classId: lessonPlanFormativeResponses.classId,
+ response: lessonPlanFormativeResponses.response,
+ isCorrect: lessonPlanFormativeResponses.isCorrect,
+ durationSec: lessonPlanFormativeResponses.durationSec,
+ createdAt: lessonPlanFormativeResponses.createdAt,
+ })
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.itemId, itemId))
.orderBy(desc(lessonPlanFormativeResponses.createdAt));
@@ -192,15 +225,35 @@ export async function getResponsesByStudentIdRaw(
if (items.length === 0) return [];
const itemIds = items.map((i) => i.id);
const rows = await db
- .select()
+ .select({
+ id: lessonPlanFormativeResponses.id,
+ itemId: lessonPlanFormativeResponses.itemId,
+ studentId: lessonPlanFormativeResponses.studentId,
+ classId: lessonPlanFormativeResponses.classId,
+ response: lessonPlanFormativeResponses.response,
+ isCorrect: lessonPlanFormativeResponses.isCorrect,
+ durationSec: lessonPlanFormativeResponses.durationSec,
+ createdAt: lessonPlanFormativeResponses.createdAt,
+ })
.from(lessonPlanFormativeResponses)
- .where(eq(lessonPlanFormativeResponses.studentId, studentId));
+ .where(eq(lessonPlanFormativeResponses.studentId, studentId))
+ .limit(DEFAULT_LIMIT);
return rows.filter((r) => itemIds.includes(r.itemId)).map(mapRowToResponse);
}
const rows = await db
- .select()
+ .select({
+ id: lessonPlanFormativeResponses.id,
+ itemId: lessonPlanFormativeResponses.itemId,
+ studentId: lessonPlanFormativeResponses.studentId,
+ classId: lessonPlanFormativeResponses.classId,
+ response: lessonPlanFormativeResponses.response,
+ isCorrect: lessonPlanFormativeResponses.isCorrect,
+ durationSec: lessonPlanFormativeResponses.durationSec,
+ createdAt: lessonPlanFormativeResponses.createdAt,
+ })
.from(lessonPlanFormativeResponses)
- .where(eq(lessonPlanFormativeResponses.studentId, studentId));
+ .where(eq(lessonPlanFormativeResponses.studentId, studentId))
+ .limit(DEFAULT_LIMIT);
return rows.map(mapRowToResponse);
}
@@ -213,7 +266,10 @@ export async function getFormativeItemStatsRaw(
itemId: string,
): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> {
const rows = await db
- .select()
+ .select({
+ isCorrect: lessonPlanFormativeResponses.isCorrect,
+ durationSec: lessonPlanFormativeResponses.durationSec,
+ })
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.itemId, itemId));
diff --git a/src/modules/lesson-preparation/data-access-knowledge.ts b/src/modules/lesson-preparation/data-access-knowledge.ts
index fd4d6bb..8b7e1ac 100644
--- a/src/modules/lesson-preparation/data-access-knowledge.ts
+++ b/src/modules/lesson-preparation/data-access-knowledge.ts
@@ -7,16 +7,12 @@ import { db } from "@/shared/db";
import { lessonPlans } from "@/shared/db/schema";
import { escapeLikePattern } from "@/shared/lib/action-utils";
import { isRecord } from "@/shared/lib/type-guards";
-import { isLessonPlanStatus } from "./lib/type-guards";
+// G1-027 去重:isStringArray 已提取至 lib/type-guards
+import { isLessonPlanStatus, isStringArray } from "./lib/type-guards";
import { normalizeDocument } from "./data-access";
import type { LessonPlanListItem } from "./types";
// ---- 安全字段提取辅助(替代 as 断言,从 BlockData 联合类型收窄)----
-// 类型守卫:判断 unknown 是否为 string[](替代 as string[] 断言)
-function isStringArray(v: unknown): v is string[] {
- return Array.isArray(v) && v.every((x) => typeof x === "string");
-}
-
function getStringArray(v: unknown): string[] | undefined {
return isStringArray(v) ? v : undefined;
}
@@ -89,8 +85,25 @@ export async function getLessonPlansByKnowledgePointRaw(
userId: string,
): Promise {
// content 是 JSON,用 LIKE 粗筛后内存精确过滤
+ // F-02: 子串匹配需 FULLTEXT 索引,短期保留(JSON 内嵌 ID 无法前缀匹配)
const rows = await db
- .select()
+ .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,
+ })
.from(lessonPlans)
.where(
and(
@@ -119,8 +132,25 @@ export async function getLessonPlansByQuestionRaw(
questionId: string,
userId: string,
): Promise {
+ // F-02: 子串匹配需 FULLTEXT 索引,短期保留(JSON 内嵌 ID 无法前缀匹配)
const rows = await db
- .select()
+ .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,
+ })
.from(lessonPlans)
.where(
and(
diff --git a/src/modules/lesson-preparation/data-access-review.ts b/src/modules/lesson-preparation/data-access-review.ts
index 2b6179a..e16654c 100644
--- a/src/modules/lesson-preparation/data-access-review.ts
+++ b/src/modules/lesson-preparation/data-access-review.ts
@@ -10,6 +10,11 @@ import { lessonPlans, lessonPlanReviewRecords } from "@/shared/db/schema";
import { and, eq, desc, asc, inArray } from "drizzle-orm";
import type { LessonPlanStatus } from "./types";
import { LESSON_PLAN_STATUS_TRANSITIONS, isReviewableStatus } from "./types";
+// G1-026 去重:toLessonPlanStatus / toReviewDecision 已提取至 lib/status-mappers
+import { toLessonPlanStatus, toReviewDecision } from "./lib/status-mappers";
+
+/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
+const DEFAULT_LIMIT = 1000;
/** 审核记录 */
export interface LessonPlanReviewRecord {
@@ -49,7 +54,9 @@ export async function submitForReview(
if (plan.length === 0) {
throw new Error("PLAN_NOT_FOUND");
}
- const currentStatus = plan[0]!.status as LessonPlanStatus;
+ const row = plan[0];
+ if (!row) throw new Error("PLAN_NOT_FOUND");
+ const currentStatus = toLessonPlanStatus(row.status);
if (!isReviewableStatus(currentStatus) && currentStatus !== "draft" && currentStatus !== "rejected") {
throw new Error("INVALID_STATUS_TRANSITION");
}
@@ -81,7 +88,9 @@ export async function reviewPlan(
if (plan.length === 0) {
throw new Error("PLAN_NOT_FOUND");
}
- const previousStatus = plan[0]!.status as LessonPlanStatus;
+ const planRow = plan[0];
+ if (!planRow) throw new Error("PLAN_NOT_FOUND");
+ const previousStatus = toLessonPlanStatus(planRow.status);
if (!isReviewableStatus(previousStatus)) {
throw new Error("PLAN_NOT_REVIEWABLE");
}
@@ -128,7 +137,16 @@ export async function getReviewRecordsByPlanIdRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanReviewRecords.id,
+ planId: lessonPlanReviewRecords.planId,
+ reviewerId: lessonPlanReviewRecords.reviewerId,
+ decision: lessonPlanReviewRecords.decision,
+ comment: lessonPlanReviewRecords.comment,
+ previousStatus: lessonPlanReviewRecords.previousStatus,
+ newStatus: lessonPlanReviewRecords.newStatus,
+ createdAt: lessonPlanReviewRecords.createdAt,
+ })
.from(lessonPlanReviewRecords)
.where(eq(lessonPlanReviewRecords.planId, planId))
.orderBy(desc(lessonPlanReviewRecords.createdAt));
@@ -137,10 +155,10 @@ export async function getReviewRecordsByPlanIdRaw(
id: r.id,
planId: r.planId,
reviewerId: r.reviewerId,
- decision: r.decision as "approved" | "rejected",
+ decision: toReviewDecision(r.decision),
comment: r.comment ?? undefined,
- previousStatus: r.previousStatus as LessonPlanStatus,
- newStatus: r.newStatus as LessonPlanStatus,
+ previousStatus: toLessonPlanStatus(r.previousStatus),
+ newStatus: toLessonPlanStatus(r.newStatus),
createdAt: r.createdAt,
}));
}
@@ -178,7 +196,8 @@ export async function getPendingReviewPlansRaw(
})
.from(lessonPlans)
.where(and(...conditions))
- .orderBy(asc(lessonPlans.updatedAt));
+ .orderBy(asc(lessonPlans.updatedAt))
+ .limit(DEFAULT_LIMIT);
// 按教研组长权限过滤(gradeId/subjectId)
return rows
@@ -194,7 +213,7 @@ export async function getPendingReviewPlansRaw(
.map((r) => ({
id: r.id,
title: r.title,
- status: r.status as LessonPlanStatus,
+ status: toLessonPlanStatus(r.status),
creatorId: r.creatorId,
gradeId: r.gradeId,
subjectId: r.subjectId,
@@ -219,7 +238,9 @@ export async function withdrawSubmission(
if (plan.length === 0) {
throw new Error("PLAN_NOT_FOUND");
}
- const currentStatus = plan[0]!.status as LessonPlanStatus;
+ const withdrawRow = plan[0];
+ if (!withdrawRow) throw new Error("PLAN_NOT_FOUND");
+ const currentStatus = toLessonPlanStatus(withdrawRow.status);
if (!isValidTransition(currentStatus, "draft")) {
throw new Error("INVALID_STATUS_TRANSITION");
}
@@ -265,12 +286,13 @@ export async function getPlansByStatusesRaw(
})
.from(lessonPlans)
.where(conditions.length > 0 ? and(...conditions) : undefined)
- .orderBy(desc(lessonPlans.updatedAt));
+ .orderBy(desc(lessonPlans.updatedAt))
+ .limit(DEFAULT_LIMIT);
return rows.map((r) => ({
id: r.id,
title: r.title,
- status: r.status as LessonPlanStatus,
+ status: toLessonPlanStatus(r.status),
creatorId: r.creatorId,
updatedAt: r.updatedAt,
}));
diff --git a/src/modules/lesson-preparation/data-access-schedules.ts b/src/modules/lesson-preparation/data-access-schedules.ts
index bba2951..a7e7360 100644
--- a/src/modules/lesson-preparation/data-access-schedules.ts
+++ b/src/modules/lesson-preparation/data-access-schedules.ts
@@ -6,10 +6,14 @@
import "server-only";
import { cacheFn } from "@/shared/lib/cache";
import { db } from "@/shared/db";
-import { lessonPlanSchedules, classes } from "@/shared/db/schema";
+import { lessonPlanSchedules } from "@/shared/db/schema";
+import { getClassNamesByIds, getClassNameById } from "@/modules/classes/data-access";
import { and, eq, gte, lte, desc } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
+/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
+const DEFAULT_LIMIT = 1000;
+
/** 课时绑定记录 */
export interface LessonPlanScheduleRecord {
id: string;
@@ -42,7 +46,6 @@ export async function getSchedulesByPlanIdRaw(
id: lessonPlanSchedules.id,
planId: lessonPlanSchedules.planId,
classId: lessonPlanSchedules.classId,
- className: classes.name,
scheduledDate: lessonPlanSchedules.scheduledDate,
period: lessonPlanSchedules.period,
classScheduleId: lessonPlanSchedules.classScheduleId,
@@ -52,15 +55,20 @@ export async function getSchedulesByPlanIdRaw(
updatedAt: lessonPlanSchedules.updatedAt,
})
.from(lessonPlanSchedules)
- .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
.where(eq(lessonPlanSchedules.planId, planId))
.orderBy(desc(lessonPlanSchedules.scheduledDate));
+ if (rows.length === 0) return [];
+
+ // 跨模块批量解析班级名称,避免直接 JOIN classes 表
+ const classIds = Array.from(new Set(rows.map((r) => r.classId)));
+ const classNameMap = await getClassNamesByIds(classIds);
+
return rows.map((r) => ({
id: r.id,
planId: r.planId,
classId: r.classId,
- className: r.className ?? "",
+ className: classNameMap.get(r.classId) ?? "",
scheduledDate: toDateStr(r.scheduledDate),
period: r.period,
classScheduleId: r.classScheduleId,
@@ -85,7 +93,6 @@ export async function getSchedulesByDateRangeRaw(
id: lessonPlanSchedules.id,
planId: lessonPlanSchedules.planId,
classId: lessonPlanSchedules.classId,
- className: classes.name,
scheduledDate: lessonPlanSchedules.scheduledDate,
period: lessonPlanSchedules.period,
classScheduleId: lessonPlanSchedules.classScheduleId,
@@ -95,7 +102,6 @@ export async function getSchedulesByDateRangeRaw(
updatedAt: lessonPlanSchedules.updatedAt,
})
.from(lessonPlanSchedules)
- .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
.where(
and(
// 简化:仅按日期范围过滤(planId 过滤由调用方处理或 join)
@@ -103,23 +109,29 @@ export async function getSchedulesByDateRangeRaw(
lte(lessonPlanSchedules.scheduledDate, new Date(endDate)),
),
)
- .orderBy(desc(lessonPlanSchedules.scheduledDate));
+ .orderBy(desc(lessonPlanSchedules.scheduledDate))
+ .limit(DEFAULT_LIMIT);
- return rows
- .filter((r) => teacherPlanIds.includes(r.planId))
- .map((r) => ({
- id: r.id,
- planId: r.planId,
- classId: r.classId,
- className: r.className ?? "",
- scheduledDate: toDateStr(r.scheduledDate),
- period: r.period,
- classScheduleId: r.classScheduleId,
- durationMin: r.durationMin,
- createdBy: r.createdBy,
- createdAt: r.createdAt,
- updatedAt: r.updatedAt,
- }));
+ const filtered = rows.filter((r) => teacherPlanIds.includes(r.planId));
+ if (filtered.length === 0) return [];
+
+ // 跨模块批量解析班级名称,避免直接 JOIN classes 表
+ const classIds = Array.from(new Set(filtered.map((r) => r.classId)));
+ const classNameMap = await getClassNamesByIds(classIds);
+
+ return filtered.map((r) => ({
+ id: r.id,
+ planId: r.planId,
+ classId: r.classId,
+ className: classNameMap.get(r.classId) ?? "",
+ scheduledDate: toDateStr(r.scheduledDate),
+ period: r.period,
+ classScheduleId: r.classScheduleId,
+ durationMin: r.durationMin,
+ createdBy: r.createdBy,
+ createdAt: r.createdAt,
+ updatedAt: r.updatedAt,
+ }));
}
export const getSchedulesByDateRange = cacheFn(getSchedulesByDateRangeRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "schedules", "by-date-range"] });
@@ -151,7 +163,6 @@ export async function createSchedule(input: {
id: lessonPlanSchedules.id,
planId: lessonPlanSchedules.planId,
classId: lessonPlanSchedules.classId,
- className: classes.name,
scheduledDate: lessonPlanSchedules.scheduledDate,
period: lessonPlanSchedules.period,
classScheduleId: lessonPlanSchedules.classScheduleId,
@@ -161,15 +172,17 @@ export async function createSchedule(input: {
updatedAt: lessonPlanSchedules.updatedAt,
})
.from(lessonPlanSchedules)
- .leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
.where(eq(lessonPlanSchedules.id, newId));
- const r = created[0]!;
+ const r = created[0];
+ if (!r) throw new Error("SCHEDULE_CREATE_FAILED");
+ // 跨模块解析班级名称,避免直接 JOIN classes 表
+ const className = (await getClassNameById(input.classId)) ?? "";
return {
id: r.id,
planId: r.planId,
classId: r.classId,
- className: r.className ?? "",
+ className,
scheduledDate: toDateStr(r.scheduledDate),
period: r.period,
classScheduleId: r.classScheduleId,
diff --git a/src/modules/lesson-preparation/data-access-substitutes.ts b/src/modules/lesson-preparation/data-access-substitutes.ts
index 288aa89..5d3a5e2 100644
--- a/src/modules/lesson-preparation/data-access-substitutes.ts
+++ b/src/modules/lesson-preparation/data-access-substitutes.ts
@@ -31,7 +31,19 @@ export async function getSubstitutesByPlanIdRaw(
planId: string,
): Promise {
const rows = await db
- .select()
+ .select({
+ id: lessonPlanSubstitutes.id,
+ planId: lessonPlanSubstitutes.planId,
+ originalTeacherId: lessonPlanSubstitutes.originalTeacherId,
+ substituteTeacherId: lessonPlanSubstitutes.substituteTeacherId,
+ startDate: lessonPlanSubstitutes.startDate,
+ endDate: lessonPlanSubstitutes.endDate,
+ reason: lessonPlanSubstitutes.reason,
+ status: lessonPlanSubstitutes.status,
+ createdBy: lessonPlanSubstitutes.createdBy,
+ createdAt: lessonPlanSubstitutes.createdAt,
+ updatedAt: lessonPlanSubstitutes.updatedAt,
+ })
.from(lessonPlanSubstitutes)
.where(eq(lessonPlanSubstitutes.planId, planId))
.orderBy(desc(lessonPlanSubstitutes.createdAt));
@@ -49,7 +61,19 @@ export async function getActiveSubstitutesByTeacherIdRaw(
): Promise {
const now = new Date();
const rows = await db
- .select()
+ .select({
+ id: lessonPlanSubstitutes.id,
+ planId: lessonPlanSubstitutes.planId,
+ originalTeacherId: lessonPlanSubstitutes.originalTeacherId,
+ substituteTeacherId: lessonPlanSubstitutes.substituteTeacherId,
+ startDate: lessonPlanSubstitutes.startDate,
+ endDate: lessonPlanSubstitutes.endDate,
+ reason: lessonPlanSubstitutes.reason,
+ status: lessonPlanSubstitutes.status,
+ createdBy: lessonPlanSubstitutes.createdBy,
+ createdAt: lessonPlanSubstitutes.createdAt,
+ updatedAt: lessonPlanSubstitutes.updatedAt,
+ })
.from(lessonPlanSubstitutes)
.where(
and(
@@ -122,7 +146,7 @@ export async function deleteSubstitute(substituteId: string): Promise {
* - 是原教师 → true
* - 是当前生效的代课教师 → true
*/
-export async function canTeacherAccessPlan(
+export async function canTeacherAccessPlanRaw(
planId: string,
teacherId: string,
): Promise {
@@ -133,12 +157,18 @@ export async function canTeacherAccessPlan(
.where(eq(lessonPlans.id, planId))
.limit(1);
if (plan.length === 0) return false;
- if (plan[0]!.creatorId === teacherId) return true;
+ const planRow = plan[0];
+ if (planRow && planRow.creatorId === teacherId) return true;
// 代课教师
const substitutes = await getActiveSubstitutesByTeacherId(teacherId);
return substitutes.some((s) => s.planId === planId);
}
+export const canTeacherAccessPlan = cacheFn(canTeacherAccessPlanRaw, {
+ tags: ["lesson-preparation"],
+ ttl: 60,
+ keyParts: ["lp", "substitutes", "can-teacher-access-plan"],
+})
function mapRowToSubstitute(
row: typeof lessonPlanSubstitutes.$inferSelect,
diff --git a/src/modules/lesson-preparation/data-access-templates.ts b/src/modules/lesson-preparation/data-access-templates.ts
index cf18420..2b1dad9 100644
--- a/src/modules/lesson-preparation/data-access-templates.ts
+++ b/src/modules/lesson-preparation/data-access-templates.ts
@@ -42,6 +42,15 @@ function mapRowToTemplate(row: {
};
}
+/**
+ * 获取可用模板列表(系统模板 + 当前用户的个人模板)。
+ *
+ * 系统模板来自内存常量 `SYSTEM_TEMPLATES`,个人模板从 DB 查询 creatorId 等于
+ * 当前用户的 personal 模板。
+ *
+ * @param userId - 当前用户 ID(用于过滤个人模板)
+ * @returns 系统模板在前、个人模板在后的合并列表
+ */
export async function getLessonPlanTemplatesRaw(
userId: string,
): Promise {
@@ -58,7 +67,16 @@ export async function getLessonPlanTemplatesRaw(
}));
const personalRows = await db
- .select()
+ .select({
+ id: lessonPlanTemplates.id,
+ name: lessonPlanTemplates.name,
+ type: lessonPlanTemplates.type,
+ scope: lessonPlanTemplates.scope,
+ blocks: lessonPlanTemplates.blocks,
+ creatorId: lessonPlanTemplates.creatorId,
+ createdAt: lessonPlanTemplates.createdAt,
+ updatedAt: lessonPlanTemplates.updatedAt,
+ })
.from(lessonPlanTemplates)
.where(
and(
@@ -73,6 +91,18 @@ export async function getLessonPlanTemplatesRaw(
export const getLessonPlanTemplates = cacheFn(getLessonPlanTemplatesRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "templates", "list"] });
+/**
+ * 将已有课案另存为个人模板。
+ *
+ * 从课案 content 中提取 block 骨架(type + title),过滤掉 textbook_content 节点,
+ * 创建一条 personal 类型、custom scope 的模板记录。
+ *
+ * @param input.sourcePlanId - 源课案 ID
+ * @param input.name - 新模板名称
+ * @param input.userId - 当前用户 ID(需为源课案创建者)
+ * @returns 新创建的模板 ID
+ * @throws {LessonPlanDataError} 当 sourcePlanId 不存在或不属于当前用户时抛出 NOT_FOUND
+ */
export async function saveAsTemplate(input: {
sourcePlanId: string;
name: string;
@@ -111,6 +141,14 @@ export async function saveAsTemplate(input: {
return { templateId };
}
+/**
+ * 删除当前用户的指定个人模板。
+ *
+ * 同时校验 type=personal 与 creatorId 匹配,防止越权删除他人模板或系统模板。
+ *
+ * @param templateId - 待删除的模板 ID
+ * @param userId - 当前用户 ID
+ */
export async function deletePersonalTemplate(
templateId: string,
userId: string,
diff --git a/src/modules/lesson-preparation/data-access-versions.ts b/src/modules/lesson-preparation/data-access-versions.ts
index ee3f0e7..0596f41 100644
--- a/src/modules/lesson-preparation/data-access-versions.ts
+++ b/src/modules/lesson-preparation/data-access-versions.ts
@@ -32,6 +32,15 @@ function mapRowToVersion(row: {
};
}
+/**
+ * 获取指定课案的全部版本列表(按版本号降序)。
+ *
+ * 仅返回当前用户创建的课案版本,校验 planId 归属。
+ *
+ * @param planId - 课案 ID
+ * @param userId - 当前用户 ID(用于归属校验)
+ * @returns 版本列表,按 versionNo 降序;归属不匹配返回空数组
+ */
export async function getLessonPlanVersionsRaw(
planId: string,
userId: string,
@@ -47,7 +56,16 @@ export async function getLessonPlanVersionsRaw(
if (plan.length === 0) return [];
const rows = await db
- .select()
+ .select({
+ id: lessonPlanVersions.id,
+ planId: lessonPlanVersions.planId,
+ versionNo: lessonPlanVersions.versionNo,
+ label: lessonPlanVersions.label,
+ content: lessonPlanVersions.content,
+ isAuto: lessonPlanVersions.isAuto,
+ creatorId: lessonPlanVersions.creatorId,
+ createdAt: lessonPlanVersions.createdAt,
+ })
.from(lessonPlanVersions)
.where(eq(lessonPlanVersions.planId, planId))
.orderBy(desc(lessonPlanVersions.versionNo));
@@ -56,6 +74,19 @@ export async function getLessonPlanVersionsRaw(
export const getLessonPlanVersions = cacheFn(getLessonPlanVersionsRaw, { tags: ["lesson-preparation"], ttl: 300, keyParts: ["lp", "versions", "by-plan"] });
+/**
+ * 为课案创建新版本。
+ *
+ * 在事务内查询 `max(versionNo)+1` 生成新版本号,避免并发产生重复版本号。
+ * 归属校验失败时返回 null。
+ *
+ * @param input.planId - 课案 ID
+ * @param input.content - 课案文档快照
+ * @param input.userId - 当前用户 ID(用于归属校验)
+ * @param input.isAuto - 是否为自动保存触发的版本
+ * @param input.label - 可选版本标签(如手动回退标签)
+ * @returns 新版本号;归属不匹配返回 null
+ */
export async function createLessonPlanVersion(input: {
planId: string;
content: LessonPlanDocument;
@@ -94,6 +125,16 @@ export async function createLessonPlanVersion(input: {
});
}
+/**
+ * 获取指定版本的课案内容快照。
+ *
+ * 校验 planId 归属与 versionNo 是否属于该 planId。
+ *
+ * @param planId - 课案 ID
+ * @param versionNo - 目标版本号
+ * @param userId - 当前用户 ID(用于归属校验)
+ * @returns 版本内容文档;归属或版本不存在返回 null
+ */
export async function getVersionContentRaw(
planId: string,
versionNo: number,
@@ -125,6 +166,18 @@ export async function getVersionContentRaw(
export const getVersionContent = cacheFn(getVersionContentRaw, { tags: ["lesson-preparation"], ttl: 600, keyParts: ["lp", "versions", "content"] });
+/**
+ * 将课案内容回退到指定版本,并生成一条新版本记录。
+ *
+ * 在事务内:1) 调用 `getVersionContent` 读取目标版本内容;2) 覆盖 `lessonPlans.content`;
+ * 3) 写入一条新版本记录(label 为 `revertLabel`)。
+ *
+ * @param planId - 课案 ID
+ * @param versionNo - 目标版本号
+ * @param userId - 当前用户 ID(用于归属校验)
+ * @param revertLabel - 由 actions 层 i18n 翻译后传入的回退标签
+ * @returns 新生成的版本号;目标版本不存在或归属不匹配返回 null
+ */
export async function revertToVersion(
planId: string,
versionNo: number,
@@ -164,6 +217,16 @@ export async function revertToVersion(
});
}
+/**
+ * 清理指定课案的过期自动版本(isAuto=true),保留最近 `keep` 条所有版本。
+ *
+ * 仅删除版本总数超出 keep 阈值后、且 isAuto=true 的版本,手动版本不受影响。
+ *
+ * @param planId - 课案 ID
+ * @param userId - 当前用户 ID(用于归属校验)
+ * @param keep - 保留的最近版本数量,默认 50
+ * @returns 实际删除的版本数量;归属不匹配返回 0
+ */
export async function pruneAutoVersions(
planId: string,
userId: string,
diff --git a/src/modules/lesson-preparation/data-access.ts b/src/modules/lesson-preparation/data-access.ts
index dd3d783..970ddff 100644
--- a/src/modules/lesson-preparation/data-access.ts
+++ b/src/modules/lesson-preparation/data-access.ts
@@ -7,8 +7,6 @@ import { db } from "@/shared/db";
import {
lessonPlans,
lessonPlanTemplates,
- textbooks,
- chapters,
subjects,
grades,
users,
@@ -23,22 +21,28 @@ import {
buildInitialContent,
buildDefaultSkeleton,
} from "./lib/document-migration";
-import { normalizeTemplateBlocks } from "./lib/type-guards";
-import { getChaptersByTextbookId, getTextbooks, findChapterById } from "@/modules/textbooks/data-access";
+// G3-036 去重:isLessonPlanStatus / isTemplateType / isTemplateScope 已集中至 lib/type-guards
+import {
+ normalizeTemplateBlocks,
+ isLessonPlanStatus,
+ isTemplateType,
+ isTemplateScope,
+} from "./lib/type-guards";
+import { getChaptersByTextbookId, getTextbooks, findChapterById, getTextbookTitlesByIds, getChapterTitlesByIds } from "@/modules/textbooks/data-access";
import type {
LessonPlan,
LessonPlanDocument,
LessonPlanListItem,
LessonPlanTemplate,
- LessonPlanStatus,
LessonPlanVersionSummary,
- TemplateType,
- TemplateScope,
} from "./types";
// re-export 纯函数保持向后兼容
export { migrateV1ToV2, normalizeDocument, buildInitialContent, buildDefaultSkeleton };
+/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
+const DEFAULT_LIMIT = 1000;
+
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
export class LessonPlanDataError extends Error {
constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") {
@@ -47,20 +51,6 @@ export class LessonPlanDataError extends Error {
}
}
-// ---- 类型守卫:安全地将 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 行 → LessonPlan(Date → ISO string)----
function mapRowToLessonPlan(row: {
id: string;
@@ -234,7 +224,8 @@ export const getLessonPlansRaw = async (
conditions.push(...buildScopeCondition(scope, userId));
if (params.query) {
- conditions.push(like(lessonPlans.title, `%${escapeLikePattern(params.query)}%`));
+ // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描
+ conditions.push(like(lessonPlans.title, `${escapeLikePattern(params.query)}%`));
}
if (params.textbookId)
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
@@ -261,22 +252,47 @@ export const getLessonPlansRaw = async (
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));
+ .orderBy(desc(lessonPlans.updatedAt))
+ .limit(DEFAULT_LIMIT);
- const items = rows.map(mapRowToListItem);
+ if (rows.length === 0) return [];
+
+ // 跨模块批量解析教材/章节标题,避免直接 JOIN textbooks/chapters 表
+ const textbookIds = Array.from(
+ new Set(
+ rows
+ .map((r) => r.textbookId)
+ .filter((v): v is string => typeof v === "string" && v.length > 0)
+ )
+ );
+ const chapterIds = Array.from(
+ new Set(
+ rows
+ .map((r) => r.chapterId)
+ .filter((v): v is string => typeof v === "string" && v.length > 0)
+ )
+ );
+ const [textbookTitleMap, chapterTitleMap] = await Promise.all([
+ getTextbookTitlesByIds(textbookIds),
+ getChapterTitlesByIds(chapterIds),
+ ]);
+
+ const items = rows.map((r) =>
+ mapRowToListItem({
+ ...r,
+ textbookTitle: r.textbookId ? textbookTitleMap.get(r.textbookId) ?? null : null,
+ chapterTitle: r.chapterId ? chapterTitleMap.get(r.chapterId) ?? null : null,
+ }),
+ );
// 版本聚合:按 textbookId + chapterId + creatorId 分组(同一教师对同一章节的多个课案视为版本)
// 无 textbookId/chapterId 的课案各自独立成组
@@ -333,7 +349,23 @@ export const getLessonPlanByIdRaw = async (
userId: string,
): Promise => {
const rows = await db
- .select()
+ .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,
+ })
.from(lessonPlans)
.where(eq(lessonPlans.id, id))
.limit(1);
@@ -413,7 +445,7 @@ export async function createLessonPlan(input: {
// (原本地函数已删除,改为从 @/modules/textbooks/data-access 导入)
// ---- 获取教材列表(供 picker 使用)----
-export async function getTextbooksForPicker(): Promise<
+export async function getTextbooksForPickerRaw(): Promise<
{ id: string; title: string; subject: string; grade: string | null }[]
> {
const textbooks = await getTextbooks();
@@ -424,9 +456,14 @@ export async function getTextbooksForPicker(): Promise<
grade: t.grade,
}));
}
+export const getTextbooksForPicker = cacheFn(getTextbooksForPickerRaw, {
+ tags: ["lesson-preparation"],
+ ttl: 300,
+ keyParts: ["lesson-preparation", "getTextbooksForPicker"],
+})
// ---- 获取章节列表(供 picker 使用)----
-export async function getChaptersForPicker(
+export async function getChaptersForPickerRaw(
textbookId: string,
): Promise<
// P1 修复:使用 ChapterPickerOption 递归类型,替代内联的 children?: unknown[]
@@ -442,6 +479,11 @@ export async function getChaptersForPicker(
children: c.children,
}));
}
+export const getChaptersForPicker = cacheFn(getChaptersForPickerRaw, {
+ tags: ["lesson-preparation"],
+ ttl: 300,
+ keyParts: ["lesson-preparation", "getChaptersForPicker"],
+})
// ---- 更新 content(自动保存,不生成版本)----
export async function updateLessonPlanContent(
@@ -532,7 +574,23 @@ export async function duplicateLessonPlan(
): Promise<{ newPlanId: string }> {
return db.transaction(async (tx) => {
const rows = await tx
- .select()
+ .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,
+ })
.from(lessonPlans)
.where(eq(lessonPlans.id, planId))
.limit(1);
@@ -571,7 +629,7 @@ export interface LessonPlanStats {
archived: number;
}
-export async function getLessonPlanStats(): Promise {
+export async function getLessonPlanStatsRaw(): Promise {
// 统计所有课案(含 archived),按 status 分组计数
const rows = await db
.select({ status: lessonPlans.status, count: sql`count(*)` })
@@ -588,9 +646,14 @@ export async function getLessonPlanStats(): Promise {
archived,
};
}
+export const getLessonPlanStats = cacheFn(getLessonPlanStatsRaw, {
+ tags: ["lesson-preparation"],
+ ttl: 60,
+ keyParts: ["lesson-preparation", "getLessonPlanStats"],
+})
// ---- 模板查询(内部)----
-export async function getTemplateById(
+export async function getTemplateByIdRaw(
templateId: string,
): Promise {
// 先查 system 固定模板
@@ -609,9 +672,23 @@ export async function getTemplateById(
}
// 再查 DB(personal 模板)
const rows = await db
- .select()
+ .select({
+ id: lessonPlanTemplates.id,
+ name: lessonPlanTemplates.name,
+ type: lessonPlanTemplates.type,
+ scope: lessonPlanTemplates.scope,
+ blocks: lessonPlanTemplates.blocks,
+ creatorId: lessonPlanTemplates.creatorId,
+ createdAt: lessonPlanTemplates.createdAt,
+ updatedAt: lessonPlanTemplates.updatedAt,
+ })
.from(lessonPlanTemplates)
.where(eq(lessonPlanTemplates.id, templateId))
.limit(1);
return rows.length > 0 ? mapRowToTemplate(rows[0]) : null;
}
+export const getTemplateById = cacheFn(getTemplateByIdRaw, {
+ tags: ["lesson-preparation"],
+ ttl: 300,
+ keyParts: ["lesson-preparation", "getTemplateById"],
+})
diff --git a/src/modules/lesson-preparation/hooks/editor-slice.ts b/src/modules/lesson-preparation/hooks/editor-slice.ts
index 00eab57..3231958 100644
--- a/src/modules/lesson-preparation/hooks/editor-slice.ts
+++ b/src/modules/lesson-preparation/hooks/editor-slice.ts
@@ -5,7 +5,6 @@ import type {
Block,
BlockData,
BlockType,
- InteractionBlockData,
LessonPlanDocument,
LessonPlanNode,
NodeAnchor,
@@ -13,6 +12,7 @@ import type {
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
+import { isInteractionBlockData } from "../lib/type-guards";
import type { EditorState } from "./use-lesson-plan-editor";
/**
@@ -21,11 +21,12 @@ import type { EditorState } from "./use-lesson-plan-editor";
*/
function cloneBlockData(data: BlockData, type: BlockType): BlockData {
if (type === "interaction") {
- const d = data as InteractionBlockData;
+ // 类型守卫安全收窄 InteractionBlockData,校验失败直接返回原 data
+ if (!isInteractionBlockData(data)) return data;
return {
- designIntent: d.designIntent,
- knowledgePointIds: [...d.knowledgePointIds],
- turns: d.turns.map((t) => ({ ...t, id: createId() })),
+ designIntent: data.designIntent,
+ knowledgePointIds: [...data.knowledgePointIds],
+ turns: data.turns.map((t) => ({ ...t, id: createId() })),
};
}
// 其他类型:JSON 深拷贝(数据全是 POJO,无函数/Date/Symbol)
@@ -121,13 +122,17 @@ export const createEditorSlice: StateCreator<
set((s) => ({
doc: {
...s.doc,
- nodes: s.doc.nodes.map((n) =>
- n.id === id
- ? n.type === "textbook_content"
- ? ({ ...n, ...patch } as TextbookContentNode)
- : ({ ...n, ...patch } as LessonPlanNode)
- : n,
- ),
+ nodes: s.doc.nodes.map((n) => {
+ if (n.id !== id) return n;
+ if (n.type === "textbook_content") {
+ // TextbookContentNode.data 为 TextbookContentNodeData,与 patch(Partial)的 data 字段类型不兼容,
+ // 需经 unknown 中间变量收窄(合法 as 场景:从 unknown 转换)
+ const merged: unknown = { ...n, ...patch };
+ return merged as TextbookContentNode;
+ }
+ // LessonPlanNode 与 patch 类型兼容,无需 as 断言
+ return { ...n, ...patch };
+ }),
},
isDirty: true,
}));
diff --git a/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts b/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts
index 3dac12b..3d5bab3 100644
--- a/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts
+++ b/src/modules/lesson-preparation/hooks/use-lesson-plan-persistence.ts
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
-import { toast } from "sonner";
+import { notify } from "@/shared/lib/notify";
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
import type {
LessonPlanDataService,
@@ -52,16 +52,16 @@ export function useLessonPlanPersistence({
content: state.doc,
});
if (res.success) {
- if (state.saveError) toast.success(t("status.recovered"));
+ if (state.saveError) notify.success(t("status.recovered"));
state.markSaved();
} else {
state.setSaveError(true);
- toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
+ notify.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
}
} catch (e) {
console.error("[LessonPlanEditor] auto-save failed", e);
state.setSaveError(true);
- toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
+ notify.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
} finally {
state.setSaving(false);
}
@@ -104,14 +104,14 @@ export function useLessonPlanPersistence({
content: state.doc,
});
if (res.success) {
- toast.success(t("status.recovered"));
+ notify.success(t("status.recovered"));
state.markSaved();
} else {
- toast.error(t("status.saveFailed"));
+ notify.error(t("status.saveFailed"));
}
} catch (e) {
console.error("[LessonPlanEditor] retry save failed", e);
- toast.error(t("status.saveFailed"));
+ notify.error(t("status.saveFailed"));
} finally {
state.setSaving(false);
}
diff --git a/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts b/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
index 8665124..31a7c4b 100644
--- a/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
+++ b/src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
@@ -2,7 +2,7 @@
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
-import { toast } from "sonner";
+import { notify } from "@/shared/lib/notify";
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
import {
generateNodeContentAction,
@@ -10,11 +10,8 @@ import {
suggestNodeDifferentiationAction,
generateLayeredQuestionsAction,
} from "../actions-ai";
-import type {
- BlockData,
- InteractionBlockData,
- LessonPlanNode,
-} from "../types";
+import type { LessonPlanNode } from "../types";
+import { isInteractionBlockData, mergeBlockDataPatch } from "../lib/type-guards";
export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered";
@@ -43,18 +40,18 @@ export function useNodeAiAssist() {
if (!node) return;
setIsRunning(true);
- toast.info(t("v4.contextMenu.aiRunning"));
+ notify.info(t("v4.contextMenu.aiRunning"));
try {
if (action === "generate") {
const res = await generateNodeContentAction(node);
if (res.success && res.data) {
- // AI patch 合并到 node.data;patch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全
- const merged = { ...node.data, ...res.data.data } as BlockData;
+ // AI patch 合并到 node.data,mergeBlockDataPatch 内部校验类型安全
+ const merged = mergeBlockDataPatch(node, res.data.data);
updateNode(nodeId, { data: merged });
- toast.success(t("v4.contextMenu.aiSuccess"));
+ notify.success(t("v4.contextMenu.aiSuccess"));
} else {
- toast.error(t("v4.contextMenu.aiFailed"));
+ notify.error(t("v4.contextMenu.aiFailed"));
}
return;
}
@@ -62,11 +59,11 @@ export function useNodeAiAssist() {
if (action === "optimize") {
const res = await optimizeNodeExpressionAction(node);
if (res.success && res.data) {
- const merged = { ...node.data, ...res.data.data } as BlockData;
+ const merged = mergeBlockDataPatch(node, res.data.data);
updateNode(nodeId, { data: merged });
- toast.success(t("v4.contextMenu.aiSuccess"));
+ notify.success(t("v4.contextMenu.aiSuccess"));
} else {
- toast.error(t("v4.contextMenu.aiFailed"));
+ notify.error(t("v4.contextMenu.aiFailed"));
}
return;
}
@@ -75,30 +72,31 @@ export function useNodeAiAssist() {
const res = await suggestNodeDifferentiationAction(node);
if (res.success && res.data) {
updateNode(nodeId, { differentiation: res.data.level });
- toast.success(`${t("v4.contextMenu.aiSuccess")}:${res.data.reason}`);
+ notify.success(`${t("v4.contextMenu.aiSuccess")}:${res.data.reason}`);
} else {
- toast.error(t("v4.contextMenu.aiFailed"));
+ notify.error(t("v4.contextMenu.aiFailed"));
}
return;
}
// layered
if (node.type !== "interaction") {
- toast.error(t("v4.contextMenu.aiFailed"));
+ notify.error(t("v4.contextMenu.aiFailed"));
return;
}
const res = await generateLayeredQuestionsAction(node);
if (res.success && res.data) {
- // interaction 节点 data 类型收窄(已通过 node.type 守卫)
- const interactionData = node.data as InteractionBlockData;
- const merged = {
- ...node.data,
- turns: [...(interactionData.turns ?? []), ...res.data.turns],
- } as BlockData;
+ // 类型守卫二次校验 node.data 为 InteractionBlockData,失败提示错误
+ if (!isInteractionBlockData(node.data)) {
+ notify.error(t("v4.contextMenu.aiFailed"));
+ return;
+ }
+ const newTurns = [...(node.data.turns ?? []), ...res.data.turns];
+ const merged = mergeBlockDataPatch(node, { turns: newTurns });
updateNode(nodeId, { data: merged });
- toast.success(t("v4.contextMenu.aiSuccess"));
+ notify.success(t("v4.contextMenu.aiSuccess"));
} else {
- toast.error(t("v4.contextMenu.aiFailed"));
+ notify.error(t("v4.contextMenu.aiFailed"));
}
} finally {
setIsRunning(false);
diff --git a/src/modules/lesson-preparation/lib/ai-node-assist.ts b/src/modules/lesson-preparation/lib/ai-node-assist.ts
index bc7e6cc..6e463cc 100644
--- a/src/modules/lesson-preparation/lib/ai-node-assist.ts
+++ b/src/modules/lesson-preparation/lib/ai-node-assist.ts
@@ -15,19 +15,18 @@ import { createAiChatCompletion } from "@/shared/lib/ai";
import { isRecord } from "@/shared/lib/type-guards";
import { z } from "zod";
import type {
- BlockData,
DifferentiationLevel,
- InteractionBlockData,
LessonPlanNode,
QATurn,
} from "../types";
+import { isInteractionBlockData } from "./type-guards";
import { createId } from "@paralleldrive/cuid2";
// ---- 公共返回类型 ----
export interface NodeContentUpdate {
- /** 合并到 node.data 的 patch */
- data: Partial;
+ /** 合并到 node.data 的 patch(AI 输出为 untrusted,类型不保证,需调用方用 mergeBlockDataPatch 校验) */
+ data: Record;
}
export interface NodeDifferentiationUpdate {
@@ -120,7 +119,7 @@ export async function generateNodeContent(
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = GenerateResultSchema.safeParse(parsed);
if (!validated.success) return null;
- return { data: validated.data.patch as Partial };
+ return { data: validated.data.patch };
} catch {
return null;
}
@@ -158,7 +157,7 @@ export async function optimizeNodeExpression(
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = OptimizeResultSchema.safeParse(parsed);
if (!validated.success) return null;
- return { data: validated.data.patch as Partial };
+ return { data: validated.data.patch };
} catch {
return null;
}
@@ -234,8 +233,10 @@ export async function generateLayeredQuestions(
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = LayeredQuestionsResultSchema.safeParse(parsed);
if (!validated.success) return null;
+ // 类型守卫校验 node.data 为 InteractionBlockData,失败返回 null
+ if (!isInteractionBlockData(node.data)) return null;
// 为每条 turn 分配 id + order
- const existing = (node.data as InteractionBlockData).turns ?? [];
+ const existing = node.data.turns ?? [];
let order = existing.length;
return validated.data.turns.map((t) => ({
id: createId(),
diff --git a/src/modules/lesson-preparation/lib/consistency-check.ts b/src/modules/lesson-preparation/lib/consistency-check.ts
index c6d3b39..9bed297 100644
--- a/src/modules/lesson-preparation/lib/consistency-check.ts
+++ b/src/modules/lesson-preparation/lib/consistency-check.ts
@@ -10,10 +10,10 @@
* 该模块为纯函数,无副作用,便于单测。
*/
import type {
- ExerciseBlockData,
LessonPlanDocument,
LessonPlanNode,
} from "../types";
+import { isExerciseBlockData } from "./type-guards";
/** 校验级别 */
export type ConsistencySeverity = "warning" | "info";
@@ -58,7 +58,9 @@ function getTeachingNodes(doc: LessonPlanDocument): LessonPlanNode[] {
/** 安全读取 exercise 节点的知识点 ID 集合 */
function getExerciseKpIds(node: LessonPlanNode): Set {
if (node.type !== "exercise") return new Set();
- const data = node.data as ExerciseBlockData;
+ // 类型守卫校验 node.data 为 ExerciseBlockData,失败返回空集合
+ if (!isExerciseBlockData(node.data)) return new Set();
+ const data = node.data;
const ids = new Set();
for (const item of data.items ?? []) {
if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {
diff --git a/src/modules/lesson-preparation/lib/export.ts b/src/modules/lesson-preparation/lib/export.ts
index ef456d8..353bba2 100644
--- a/src/modules/lesson-preparation/lib/export.ts
+++ b/src/modules/lesson-preparation/lib/export.ts
@@ -17,6 +17,7 @@ import type {
KeyPointBlockData,
LessonPlan,
LessonPlanDocument,
+ LessonPlanNode,
NewTeachingBlockData,
ObjectiveBlockData,
ReflectionBlockData,
@@ -25,6 +26,20 @@ import type {
TextStudyBlockData,
TextbookContentNode,
} from "../types";
+import {
+ isBlackboardBlockData,
+ isExerciseBlockData,
+ isHomeworkBlockData,
+ isImportBlockData,
+ isInteractionBlockData,
+ isKeyPointBlockData,
+ isNewTeachingBlockData,
+ isObjectiveBlockData,
+ isReflectionBlockData,
+ isRichTextBlockData,
+ isSummaryBlockData,
+ isTextStudyBlockData,
+} from "./type-guards";
/** 导出版本 */
export type ExportVariant = "detailed" | "concise";
@@ -83,20 +98,21 @@ export function flattenLessonPlanForPrint(
const doc: LessonPlanDocument = plan.content;
const textbookContent = extractTextbookContent(doc);
const teachingNodes = doc.nodes
- .filter((n) => n.type !== "textbook_content")
+ .filter((n): n is LessonPlanNode => n.type !== "textbook_content")
.filter((n) => variant === "detailed" || CONCISE_BLOCK_TYPES.has(n.type))
.sort((a, b) => a.order - b.order);
const sections = teachingNodes.map((node) =>
- flattenBlock(node.type, node.title, node.data as BlockData),
+ flattenBlock(node.type, node.title, node.data),
);
// V5-4:教学时长由 import 节点求和
const totalDurationMin = doc.nodes
.filter((n) => n.type === "import")
.reduce((sum, n) => {
- const data = n.data as ImportBlockData;
- return sum + (data.durationMin ?? 0);
+ // 类型守卫校验 import 节点数据结构,替代 as ImportBlockData 断言
+ if (!isImportBlockData(n.data)) return sum;
+ return sum + (n.data.durationMin ?? 0);
}, 0);
return {
@@ -132,29 +148,29 @@ function flattenBlock(
function flattenBlockData(type: string, data: BlockData): string[] {
switch (type) {
case "objective":
- return flattenObjective(data as ObjectiveBlockData);
+ return isObjectiveBlockData(data) ? flattenObjective(data) : [];
case "key_point":
- return flattenKeyPoint(data as KeyPointBlockData);
+ return isKeyPointBlockData(data) ? flattenKeyPoint(data) : [];
case "import":
- return flattenImport(data as ImportBlockData);
+ return isImportBlockData(data) ? flattenImport(data) : [];
case "new_teaching":
- return flattenNewTeaching(data as NewTeachingBlockData);
+ return isNewTeachingBlockData(data) ? flattenNewTeaching(data) : [];
case "summary":
- return flattenSummary(data as SummaryBlockData);
+ return isSummaryBlockData(data) ? flattenSummary(data) : [];
case "homework":
- return flattenHomework(data as HomeworkBlockData);
+ return isHomeworkBlockData(data) ? flattenHomework(data) : [];
case "blackboard":
- return flattenBlackboard(data as BlackboardBlockData);
+ return isBlackboardBlockData(data) ? flattenBlackboard(data) : [];
case "reflection":
- return flattenReflection(data as ReflectionBlockData);
+ return isReflectionBlockData(data) ? flattenReflection(data) : [];
case "exercise":
- return flattenExercise(data as ExerciseBlockData);
+ return isExerciseBlockData(data) ? flattenExercise(data) : [];
case "text_study":
- return flattenTextStudy(data as TextStudyBlockData);
+ return isTextStudyBlockData(data) ? flattenTextStudy(data) : [];
case "rich_text":
- return flattenRichText(data as RichTextBlockData);
+ return isRichTextBlockData(data) ? flattenRichText(data) : [];
case "interaction":
- return flattenInteraction(data as InteractionBlockData);
+ return isInteractionBlockData(data) ? flattenInteraction(data) : [];
default:
return [];
}
diff --git a/src/modules/lesson-preparation/lib/type-guards.ts b/src/modules/lesson-preparation/lib/type-guards.ts
index 47f7b04..c3deb11 100644
--- a/src/modules/lesson-preparation/lib/type-guards.ts
+++ b/src/modules/lesson-preparation/lib/type-guards.ts
@@ -3,6 +3,7 @@ import type {
BlackboardBlockData,
BlockData,
BlockType,
+ DifferentiationLevel,
ExerciseBlockData,
ExercisePurpose,
HomeworkAssignment,
@@ -19,6 +20,7 @@ import type {
ReflectionItem,
RichTextBlockData,
SummaryBlockData,
+ TeachingStage,
TemplateBlockSkeleton,
TemplateScope,
TemplateType,
@@ -93,11 +95,11 @@ function isObject(v: unknown): v is Record {
return typeof v === "object" && v !== null;
}
-export function isRichTextBlockData(data: BlockData): data is RichTextBlockData {
+export function isRichTextBlockData(data: unknown): data is RichTextBlockData {
return isObject(data) && typeof data.html === "string" && Array.isArray(data.knowledgePointIds);
}
-export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockData {
+export function isTextStudyBlockData(data: unknown): data is TextStudyBlockData {
return (
isObject(data) &&
typeof data.sourceText === "string" &&
@@ -106,7 +108,7 @@ export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockDat
);
}
-export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData {
+export function isExerciseBlockData(data: unknown): data is ExerciseBlockData {
return (
isObject(data) &&
Array.isArray(data.items) &&
@@ -114,15 +116,15 @@ export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData
);
}
-export function isObjectiveBlockData(data: BlockData): data is ObjectiveBlockData {
+export function isObjectiveBlockData(data: unknown): data is ObjectiveBlockData {
return isObject(data) && Array.isArray(data.objectives);
}
-export function isKeyPointBlockData(data: BlockData): data is KeyPointBlockData {
+export function isKeyPointBlockData(data: unknown): data is KeyPointBlockData {
return isObject(data) && Array.isArray(data.keyPoints);
}
-export function isImportBlockData(data: BlockData): data is ImportBlockData {
+export function isImportBlockData(data: unknown): data is ImportBlockData {
return (
isObject(data) &&
typeof data.prompt === "string" &&
@@ -131,19 +133,19 @@ export function isImportBlockData(data: BlockData): data is ImportBlockData {
);
}
-export function isNewTeachingBlockData(data: BlockData): data is NewTeachingBlockData {
+export function isNewTeachingBlockData(data: unknown): data is NewTeachingBlockData {
return isObject(data) && Array.isArray(data.teachingPoints);
}
-export function isSummaryBlockData(data: BlockData): data is SummaryBlockData {
+export function isSummaryBlockData(data: unknown): data is SummaryBlockData {
return isObject(data) && Array.isArray(data.summaryPoints) && typeof data.homeworkPreview === "string";
}
-export function isHomeworkBlockData(data: BlockData): data is HomeworkBlockData {
+export function isHomeworkBlockData(data: unknown): data is HomeworkBlockData {
return isObject(data) && Array.isArray(data.assignments);
}
-export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockData {
+export function isBlackboardBlockData(data: unknown): data is BlackboardBlockData {
return (
isObject(data) &&
typeof data.layout === "string" &&
@@ -152,7 +154,7 @@ export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockD
);
}
-export function isReflectionBlockData(data: BlockData): data is ReflectionBlockData {
+export function isReflectionBlockData(data: unknown): data is ReflectionBlockData {
return isObject(data) && Array.isArray(data.reflection);
}
@@ -215,6 +217,38 @@ export function isReflectionAspect(
return (REFLECTION_ASPECTS as readonly string[]).includes(v);
}
+// ---- 节点属性枚举守卫 ----
+
+const TEACHING_STAGES = ["import", "new_teaching", "consolidation", "summary"] as const;
+export function isTeachingStage(v: string): v is TeachingStage {
+ return (TEACHING_STAGES as readonly string[]).includes(v);
+}
+
+const DIFFERENTIATION_LEVELS = ["basic", "intermediate", "advanced"] as const;
+export function isDifferentiationLevel(v: string): v is DifferentiationLevel {
+ return (DIFFERENTIATION_LEVELS as readonly string[]).includes(v);
+}
+
+// ---- AI 反馈分类守卫 ----
+
+const AI_FEEDBACK_CATEGORIES = [
+ "strengths",
+ "improvements",
+ "alignment",
+ "differentiation",
+] as const;
+export type AiFeedbackCategory = (typeof AI_FEEDBACK_CATEGORIES)[number];
+export function isAiFeedbackCategory(v: string): v is AiFeedbackCategory {
+ return (AI_FEEDBACK_CATEGORIES as readonly string[]).includes(v);
+}
+
+// ---- 通用数组守卫 ----
+
+/** 判断 unknown 是否为 string[](用于 DB JSON 字段安全收窄) */
+export function isStringArray(v: unknown): v is string[] {
+ return Array.isArray(v) && v.every((item) => typeof item === "string");
+}
+
// ---- 节点类型守卫 ----
export function isTextbookContentNode(
node: { type: string },
@@ -262,6 +296,62 @@ export function isBlockType(v: string): v is BlockType {
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);
}
+// ---- BlockData 合并校验(替代 `{ ...node.data, ...patch } as BlockData` 断言)----
+
+/**
+ * 根据节点 type 调用对应的类型守卫,校验 unknown 是否为该 type 的合法 BlockData。
+ * consolidation 节点复用 rich_text 守卫(与 block-registry 渲染逻辑一致)。
+ */
+function isValidBlockDataForType(data: unknown, type: BlockType): boolean {
+ switch (type) {
+ case "rich_text":
+ case "consolidation":
+ return isRichTextBlockData(data);
+ case "text_study":
+ return isTextStudyBlockData(data);
+ case "exercise":
+ return isExerciseBlockData(data);
+ case "objective":
+ return isObjectiveBlockData(data);
+ case "key_point":
+ return isKeyPointBlockData(data);
+ case "import":
+ return isImportBlockData(data);
+ case "new_teaching":
+ return isNewTeachingBlockData(data);
+ case "summary":
+ return isSummaryBlockData(data);
+ case "homework":
+ return isHomeworkBlockData(data);
+ case "blackboard":
+ return isBlackboardBlockData(data);
+ case "reflection":
+ return isReflectionBlockData(data);
+ case "interaction":
+ return isInteractionBlockData(data);
+ default:
+ return false;
+ }
+}
+
+/**
+ * 合并 AI patch 到节点 data,自动校验结果是否仍为合法 BlockData。
+ * 校验失败时回退到原 node.data,保证类型安全。
+ * 替代 `{ ...node.data, ...patch } as BlockData` 断言。
+ */
+export function mergeBlockDataPatch(
+ node: LessonPlanNode,
+ patch: Record,
+): BlockData {
+ const merged: unknown = { ...node.data, ...patch };
+ if (isValidBlockDataForType(merged, node.type)) {
+ // 从 unknown 收窄到 BlockData(合法 as 场景:已通过对应类型守卫校验)
+ return merged as BlockData;
+ }
+ // 校验失败,回退原 data 保证类型安全
+ return node.data;
+}
+
// ---- TemplateBlockSkeleton 守卫与规范化(替代 as 断言从 DB unknown 转换)----
// isObject 已在上方定义(第 56 行),复用同一类型守卫
diff --git a/src/modules/lesson-preparation/publish-service.ts b/src/modules/lesson-preparation/publish-service.ts
index 268a0f5..90e3f73 100644
--- a/src/modules/lesson-preparation/publish-service.ts
+++ b/src/modules/lesson-preparation/publish-service.ts
@@ -61,7 +61,23 @@ export async function publishLessonPlanHomework(
): Promise {
// 1. 读取课案
const rows = await db
- .select()
+ .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,
+ })
.from(lessonPlans)
.where(eq(lessonPlans.id, input.planId))
.limit(1);
diff --git a/src/modules/lesson-preparation/services/default-question-service.ts b/src/modules/lesson-preparation/services/default-question-service.ts
index 153821e..7e11fb9 100644
--- a/src/modules/lesson-preparation/services/default-question-service.ts
+++ b/src/modules/lesson-preparation/services/default-question-service.ts
@@ -2,7 +2,6 @@
import { getQuestionsForPickerAction } from "../actions-questions";
import type {
- QuestionPickerItem,
QuestionPickerParams,
QuestionService,
} from "../providers/lesson-plan-provider";
@@ -22,7 +21,7 @@ export function createDefaultQuestionService(): QuestionService {
async getQuestions(params: QuestionPickerParams) {
const res = await getQuestionsForPickerAction(params);
if (res.success && res.data) {
- return { success: true, data: { data: res.data.data as QuestionPickerItem[] } };
+ return { success: true, data: { data: res.data.data } };
}
return { success: false, message: res.message };
},