将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 knowledgePointPrerequisites 表(复合主键+双外键 cascade);新增 data-access-graph.ts(server-only)知识点关联聚合、学生/班级掌握度查询;utils.ts 新增 hasCycleAfterAddingEdge(DFS 循环依赖检测)。 业务层:3 个新 Server Action(getKnowledgeGraphDataAction 三视图模式、createPrerequisiteAction 含循环检测、deletePrerequisiteAction);graph-layout.ts 重写为 dagre 分层有向图布局。 视图层:knowledge-graph.tsx 重写为 React Flow 主组件(全书视图+搜索高亮+关联节点高亮+章节着色);4 个新组件(graph-kp-node/graph-prerequisite-edge/graph-toolbar/graph-node-detail-panel);use-graph-data.ts 派生值模式避免 effect 中 setState。 架构:严格三层架构,客户端通过 Server Action 间接访问 server-only 数据层;权限校验+ i18n 全覆盖;架构文档 004/005 同步。 测试:utils.test.ts 新增 5 个循环检测测试,graph-layout.test.ts 重写 5 个 dagre 布局测试,全部 30 个教材模块单元测试通过。 附带提交 drizzle/0005 error-book 迁移文件以保持 journal 一致性。
663 lines
19 KiB
TypeScript
663 lines
19 KiB
TypeScript
import "server-only"
|
||
|
||
import { cache } from "react"
|
||
import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
|
||
import { createId } from "@paralleldrive/cuid2"
|
||
|
||
import { db } from "@/shared/db"
|
||
import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"
|
||
import { escapeLikePattern } from "@/shared/lib/action-utils"
|
||
import type {
|
||
Chapter,
|
||
KnowledgePoint,
|
||
Textbook,
|
||
} from "./types"
|
||
import type {
|
||
CreateChapterInput,
|
||
CreateKnowledgePointInput,
|
||
CreatePrerequisiteInput,
|
||
CreateTextbookInput,
|
||
DeletePrerequisiteInput,
|
||
UpdateChapterContentInput,
|
||
UpdateKnowledgePointInput,
|
||
UpdateTextbookInput,
|
||
} from "./schema"
|
||
import {
|
||
buildChapterTree,
|
||
normalizeOptional,
|
||
sortChapters,
|
||
} from "./utils"
|
||
|
||
export { buildChapterTree, normalizeOptional, sortChapters }
|
||
|
||
/**
|
||
* 数据范围过滤参数。
|
||
* 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。
|
||
*/
|
||
export interface TextbookQueryScope {
|
||
/** 按年级过滤(学生端传入学生所在年级) */
|
||
grade?: string
|
||
}
|
||
|
||
export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
|
||
const conditions: SQL[] = []
|
||
|
||
const q = query?.trim()
|
||
if (q) {
|
||
const needle = `%${escapeLikePattern(q)}%`
|
||
const nameCond = or(
|
||
like(textbooks.title, needle),
|
||
like(textbooks.subject, needle),
|
||
like(textbooks.grade, needle),
|
||
like(textbooks.publisher, needle)
|
||
)
|
||
if (nameCond) conditions.push(nameCond)
|
||
}
|
||
|
||
const s = subject?.trim()
|
||
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
|
||
|
||
const g = grade?.trim()
|
||
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
|
||
|
||
const rows = await db
|
||
.select({
|
||
id: textbooks.id,
|
||
title: textbooks.title,
|
||
subject: textbooks.subject,
|
||
grade: textbooks.grade,
|
||
publisher: textbooks.publisher,
|
||
createdAt: textbooks.createdAt,
|
||
updatedAt: textbooks.updatedAt,
|
||
chaptersCount: sql<number>`COUNT(${chapters.id})`,
|
||
})
|
||
.from(textbooks)
|
||
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
|
||
.where(conditions.length ? and(...conditions) : undefined)
|
||
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt)
|
||
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
subject: r.subject,
|
||
grade: r.grade,
|
||
publisher: r.publisher,
|
||
createdAt: r.createdAt,
|
||
updatedAt: r.updatedAt,
|
||
_count: { chapters: Number(r.chaptersCount ?? 0) },
|
||
}))
|
||
})
|
||
|
||
export const getTextbookById = cache(async (id: string): Promise<Textbook | undefined> => {
|
||
const [row] = await db
|
||
.select({
|
||
id: textbooks.id,
|
||
title: textbooks.title,
|
||
subject: textbooks.subject,
|
||
grade: textbooks.grade,
|
||
publisher: textbooks.publisher,
|
||
createdAt: textbooks.createdAt,
|
||
updatedAt: textbooks.updatedAt,
|
||
chaptersCount: sql<number>`COUNT(${chapters.id})`,
|
||
})
|
||
.from(textbooks)
|
||
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
|
||
.where(eq(textbooks.id, id))
|
||
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt)
|
||
.limit(1)
|
||
|
||
if (!row) return undefined
|
||
|
||
return {
|
||
id: row.id,
|
||
title: row.title,
|
||
subject: row.subject,
|
||
grade: row.grade,
|
||
publisher: row.publisher,
|
||
createdAt: row.createdAt,
|
||
updatedAt: row.updatedAt,
|
||
_count: { chapters: Number(row.chaptersCount ?? 0) },
|
||
}
|
||
})
|
||
|
||
export const getChaptersByTextbookId = cache(async (textbookId: string): Promise<Chapter[]> => {
|
||
const rows = await db
|
||
.select({
|
||
id: chapters.id,
|
||
textbookId: chapters.textbookId,
|
||
title: chapters.title,
|
||
order: chapters.order,
|
||
parentId: chapters.parentId,
|
||
content: chapters.content,
|
||
createdAt: chapters.createdAt,
|
||
updatedAt: chapters.updatedAt,
|
||
})
|
||
.from(chapters)
|
||
.where(eq(chapters.textbookId, textbookId))
|
||
.orderBy(asc(chapters.order), asc(chapters.title))
|
||
|
||
return buildChapterTree(
|
||
rows.map((r) => ({
|
||
id: r.id,
|
||
textbookId: r.textbookId,
|
||
title: r.title,
|
||
order: r.order,
|
||
parentId: r.parentId,
|
||
content: r.content ?? null,
|
||
createdAt: r.createdAt,
|
||
updatedAt: r.updatedAt,
|
||
}))
|
||
)
|
||
})
|
||
|
||
export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> {
|
||
const id = createId()
|
||
const now = new Date()
|
||
|
||
const row = {
|
||
id,
|
||
title: data.title.trim(),
|
||
subject: data.subject.trim(),
|
||
grade: normalizeOptional(data.grade),
|
||
publisher: normalizeOptional(data.publisher),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}
|
||
|
||
await db.insert(textbooks).values(row)
|
||
|
||
return {
|
||
...row,
|
||
_count: { chapters: 0 },
|
||
}
|
||
}
|
||
|
||
export async function updateTextbook(data: UpdateTextbookInput): Promise<Textbook> {
|
||
await db
|
||
.update(textbooks)
|
||
.set({
|
||
title: data.title.trim(),
|
||
subject: data.subject.trim(),
|
||
grade: normalizeOptional(data.grade),
|
||
publisher: normalizeOptional(data.publisher),
|
||
})
|
||
.where(eq(textbooks.id, data.id))
|
||
|
||
const updated = await getTextbookById(data.id)
|
||
if (!updated) throw new Error("Textbook not found")
|
||
return updated
|
||
}
|
||
|
||
export async function deleteTextbook(id: string): Promise<void> {
|
||
await db.delete(textbooks).where(eq(textbooks.id, id))
|
||
}
|
||
|
||
export async function createChapter(data: CreateChapterInput): Promise<Chapter> {
|
||
const id = createId()
|
||
const now = new Date()
|
||
|
||
const row = {
|
||
id,
|
||
textbookId: data.textbookId,
|
||
title: data.title.trim(),
|
||
order: data.order ?? 0,
|
||
parentId: data.parentId ?? null,
|
||
content: "",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
}
|
||
|
||
await db.insert(chapters).values(row)
|
||
|
||
return {
|
||
id: row.id,
|
||
textbookId: row.textbookId,
|
||
title: row.title,
|
||
order: row.order,
|
||
parentId: row.parentId,
|
||
content: row.content,
|
||
createdAt: row.createdAt,
|
||
updatedAt: row.updatedAt,
|
||
children: [],
|
||
}
|
||
}
|
||
|
||
export async function updateChapterContent(data: UpdateChapterContentInput): Promise<Chapter> {
|
||
await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId))
|
||
|
||
const [row] = await db
|
||
.select({
|
||
id: chapters.id,
|
||
textbookId: chapters.textbookId,
|
||
title: chapters.title,
|
||
order: chapters.order,
|
||
parentId: chapters.parentId,
|
||
content: chapters.content,
|
||
createdAt: chapters.createdAt,
|
||
updatedAt: chapters.updatedAt,
|
||
})
|
||
.from(chapters)
|
||
.where(eq(chapters.id, data.chapterId))
|
||
.limit(1)
|
||
|
||
if (!row) throw new Error("Chapter not found")
|
||
|
||
return {
|
||
id: row.id,
|
||
textbookId: row.textbookId,
|
||
title: row.title,
|
||
order: row.order,
|
||
parentId: row.parentId,
|
||
content: row.content ?? null,
|
||
createdAt: row.createdAt,
|
||
updatedAt: row.updatedAt,
|
||
children: [],
|
||
}
|
||
}
|
||
|
||
export async function deleteChapter(id: string): Promise<void> {
|
||
const [target] = await db
|
||
.select({ id: chapters.id, textbookId: chapters.textbookId })
|
||
.from(chapters)
|
||
.where(eq(chapters.id, id))
|
||
.limit(1)
|
||
|
||
if (!target) return
|
||
|
||
const all = await db
|
||
.select({ id: chapters.id, parentId: chapters.parentId })
|
||
.from(chapters)
|
||
.where(eq(chapters.textbookId, target.textbookId))
|
||
|
||
const childrenByParent = new Map<string, string[]>()
|
||
for (const ch of all) {
|
||
if (!ch.parentId) continue
|
||
const arr = childrenByParent.get(ch.parentId) ?? []
|
||
arr.push(ch.id)
|
||
childrenByParent.set(ch.parentId, arr)
|
||
}
|
||
|
||
const idsToDelete: string[] = []
|
||
const stack: string[] = [id]
|
||
const seen = new Set<string>()
|
||
while (stack.length) {
|
||
const cur = stack.pop()
|
||
if (!cur) break
|
||
if (seen.has(cur)) continue
|
||
seen.add(cur)
|
||
idsToDelete.push(cur)
|
||
const kids = childrenByParent.get(cur)
|
||
if (kids) stack.push(...kids)
|
||
}
|
||
|
||
await db.transaction(async (tx) => {
|
||
await tx.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
|
||
await tx.delete(chapters).where(inArray(chapters.id, idsToDelete))
|
||
})
|
||
}
|
||
|
||
export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise<KnowledgePoint[]> => {
|
||
const rows = await db
|
||
.select({
|
||
id: knowledgePoints.id,
|
||
name: knowledgePoints.name,
|
||
description: knowledgePoints.description,
|
||
parentId: knowledgePoints.parentId,
|
||
chapterId: knowledgePoints.chapterId,
|
||
level: knowledgePoints.level,
|
||
order: knowledgePoints.order,
|
||
})
|
||
.from(knowledgePoints)
|
||
.where(eq(knowledgePoints.chapterId, chapterId))
|
||
.orderBy(asc(knowledgePoints.order), asc(knowledgePoints.name))
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
description: r.description ?? null,
|
||
parentId: r.parentId ?? null,
|
||
chapterId: r.chapterId ?? undefined,
|
||
level: r.level ?? 0,
|
||
order: r.order ?? 0,
|
||
}))
|
||
})
|
||
|
||
export const getKnowledgePointsByTextbookId = cache(async (textbookId: string): Promise<KnowledgePoint[]> => {
|
||
const rows = await db
|
||
.select({
|
||
id: knowledgePoints.id,
|
||
name: knowledgePoints.name,
|
||
description: knowledgePoints.description,
|
||
parentId: knowledgePoints.parentId,
|
||
chapterId: knowledgePoints.chapterId,
|
||
level: knowledgePoints.level,
|
||
order: knowledgePoints.order,
|
||
})
|
||
.from(knowledgePoints)
|
||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||
.where(eq(chapters.textbookId, textbookId))
|
||
.orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name))
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
description: r.description ?? null,
|
||
parentId: r.parentId ?? null,
|
||
chapterId: r.chapterId ?? undefined,
|
||
level: r.level ?? 0,
|
||
order: r.order ?? 0,
|
||
}))
|
||
})
|
||
|
||
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> {
|
||
await db.insert(knowledgePoints).values({
|
||
id: createId(),
|
||
name: data.name,
|
||
description: data.description,
|
||
anchorText: data.anchorText,
|
||
chapterId: data.chapterId,
|
||
parentId: data.parentId,
|
||
level: 0, // Default level
|
||
order: 0, // Default order
|
||
})
|
||
}
|
||
|
||
export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise<void> {
|
||
await db
|
||
.update(knowledgePoints)
|
||
.set({
|
||
name: data.name,
|
||
description: data.description,
|
||
anchorText: data.anchorText,
|
||
})
|
||
.where(eq(knowledgePoints.id, data.id))
|
||
}
|
||
|
||
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
||
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
|
||
}
|
||
|
||
export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise<void> {
|
||
const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1)
|
||
if (!target) throw new Error("Chapter not found")
|
||
|
||
const siblings = await db
|
||
.select()
|
||
.from(chapters)
|
||
.where(
|
||
and(
|
||
eq(chapters.textbookId, target.textbookId),
|
||
parentId ? eq(chapters.parentId, parentId) : isNull(chapters.parentId)
|
||
)
|
||
)
|
||
.orderBy(asc(chapters.order))
|
||
|
||
const currentSiblings = siblings.filter((c) => c.id !== chapterId)
|
||
currentSiblings.splice(newIndex, 0, target)
|
||
|
||
await db.transaction(async (tx) => {
|
||
for (let i = 0; i < currentSiblings.length; i++) {
|
||
const ch = currentSiblings[i]
|
||
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
|
||
await tx
|
||
.update(chapters)
|
||
.set({
|
||
order: i,
|
||
parentId: ch.id === chapterId ? parentId : ch.parentId
|
||
})
|
||
.where(eq(chapters.id, ch.id))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
export type TextbooksDashboardStats = {
|
||
textbookCount: number
|
||
chapterCount: number
|
||
}
|
||
|
||
export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashboardStats> => {
|
||
const [textbookCountRow, chapterCountRow] = await Promise.all([
|
||
db.select({ value: count() }).from(textbooks),
|
||
db.select({ value: count() }).from(chapters),
|
||
])
|
||
return {
|
||
textbookCount: Number(textbookCountRow[0]?.value ?? 0),
|
||
chapterCount: Number(chapterCountRow[0]?.value ?? 0),
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 资源归属校验(P0-4)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 校验章节是否属于指定教材。
|
||
*
|
||
* 用于 Server Action 二次校验,防止越权操作其他教材的章节。
|
||
*
|
||
* @returns true 表示归属一致
|
||
*/
|
||
export async function verifyChapterBelongsToTextbook(
|
||
chapterId: string,
|
||
textbookId: string
|
||
): Promise<boolean> {
|
||
const [row] = await db
|
||
.select({ textbookId: chapters.textbookId })
|
||
.from(chapters)
|
||
.where(eq(chapters.id, chapterId))
|
||
.limit(1)
|
||
|
||
if (!row) return false
|
||
return row.textbookId === textbookId
|
||
}
|
||
|
||
/**
|
||
* 校验知识点是否属于指定章节。
|
||
*/
|
||
export async function verifyKnowledgePointBelongsToChapter(
|
||
kpId: string,
|
||
chapterId: string
|
||
): Promise<boolean> {
|
||
const [row] = await db
|
||
.select({ chapterId: knowledgePoints.chapterId })
|
||
.from(knowledgePoints)
|
||
.where(eq(knowledgePoints.id, kpId))
|
||
.limit(1)
|
||
|
||
if (!row) return false
|
||
return row.chapterId === chapterId
|
||
}
|
||
|
||
/**
|
||
* 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。
|
||
*
|
||
* 用于 Server Action 二次校验,防止越权操作其他教材的知识点。
|
||
*/
|
||
export async function verifyKnowledgePointBelongsToTextbook(
|
||
kpId: string,
|
||
textbookId: string
|
||
): Promise<boolean> {
|
||
const [row] = await db
|
||
.select({ textbookId: chapters.textbookId })
|
||
.from(knowledgePoints)
|
||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||
.where(eq(knowledgePoints.id, kpId))
|
||
.limit(1)
|
||
|
||
if (!row) return false
|
||
return row.textbookId === textbookId
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 带 scope 的查询(P1-1 数据范围过滤)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 按数据范围获取教材列表。
|
||
*
|
||
* 学生端应传入 `scope.grade` 按年级过滤,避免跨年级越权读取。
|
||
*/
|
||
export const getTextbooksWithScope = cache(
|
||
async (
|
||
query?: string,
|
||
subject?: string,
|
||
grade?: string,
|
||
scope?: TextbookQueryScope
|
||
): Promise<Textbook[]> => {
|
||
const conditions: SQL[] = []
|
||
|
||
const q = query?.trim()
|
||
if (q) {
|
||
const needle = `%${escapeLikePattern(q)}%`
|
||
const nameCond = or(
|
||
like(textbooks.title, needle),
|
||
like(textbooks.subject, needle),
|
||
like(textbooks.grade, needle),
|
||
like(textbooks.publisher, needle)
|
||
)
|
||
if (nameCond) conditions.push(nameCond)
|
||
}
|
||
|
||
const s = subject?.trim()
|
||
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
|
||
|
||
// URL 参数 grade(用户筛选)
|
||
const g = grade?.trim()
|
||
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
|
||
|
||
// scope.grade(数据范围过滤,学生端强制按年级过滤)
|
||
const scopeGrade = scope?.grade?.trim()
|
||
if (scopeGrade) conditions.push(eq(textbooks.grade, scopeGrade))
|
||
|
||
const rows = await db
|
||
.select({
|
||
id: textbooks.id,
|
||
title: textbooks.title,
|
||
subject: textbooks.subject,
|
||
grade: textbooks.grade,
|
||
publisher: textbooks.publisher,
|
||
createdAt: textbooks.createdAt,
|
||
updatedAt: textbooks.updatedAt,
|
||
chaptersCount: sql<number>`COUNT(${chapters.id})`,
|
||
})
|
||
.from(textbooks)
|
||
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
|
||
.where(conditions.length ? and(...conditions) : undefined)
|
||
.groupBy(
|
||
textbooks.id,
|
||
textbooks.title,
|
||
textbooks.subject,
|
||
textbooks.grade,
|
||
textbooks.publisher,
|
||
textbooks.createdAt,
|
||
textbooks.updatedAt
|
||
)
|
||
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
|
||
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
subject: r.subject,
|
||
grade: r.grade,
|
||
publisher: r.publisher,
|
||
createdAt: r.createdAt,
|
||
updatedAt: r.updatedAt,
|
||
_count: { chapters: Number(r.chaptersCount ?? 0) },
|
||
}))
|
||
}
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cross-module query interfaces — read-only access for other modules
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type KnowledgePointOption = {
|
||
id: string
|
||
name: string
|
||
chapterId: string | null
|
||
chapterTitle: string | null
|
||
textbookId: string | null
|
||
textbookTitle: string | null
|
||
subject: string | null
|
||
grade: string | null
|
||
}
|
||
|
||
/**
|
||
* 获取所有知识点选项(含章节和教材信息)。
|
||
* 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。
|
||
*/
|
||
export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOption[]> => {
|
||
const rows = await db
|
||
.select({
|
||
id: knowledgePoints.id,
|
||
name: knowledgePoints.name,
|
||
chapterId: chapters.id,
|
||
chapterTitle: chapters.title,
|
||
textbookId: textbooks.id,
|
||
textbookTitle: textbooks.title,
|
||
subject: textbooks.subject,
|
||
grade: textbooks.grade,
|
||
})
|
||
.from(knowledgePoints)
|
||
.leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||
.leftJoin(textbooks, eq(textbooks.id, chapters.textbookId))
|
||
.orderBy(
|
||
asc(textbooks.title),
|
||
asc(chapters.order),
|
||
asc(chapters.title),
|
||
asc(knowledgePoints.order),
|
||
asc(knowledgePoints.name)
|
||
)
|
||
|
||
return rows.map((row) => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
chapterId: row.chapterId ?? null,
|
||
chapterTitle: row.chapterTitle ?? null,
|
||
textbookId: row.textbookId ?? null,
|
||
textbookTitle: row.textbookTitle ?? null,
|
||
subject: row.subject ?? null,
|
||
grade: row.grade ?? null,
|
||
}))
|
||
})
|
||
|
||
// ===== Prerequisite CRUD =====
|
||
|
||
export async function createPrerequisite(data: CreatePrerequisiteInput): Promise<void> {
|
||
await db.insert(knowledgePointPrerequisites).values({
|
||
knowledgePointId: data.knowledgePointId,
|
||
prerequisiteKpId: data.prerequisiteKpId,
|
||
})
|
||
}
|
||
|
||
export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise<void> {
|
||
await db
|
||
.delete(knowledgePointPrerequisites)
|
||
.where(and(
|
||
eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId),
|
||
eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId),
|
||
))
|
||
}
|
||
|
||
/**
|
||
* 获取教材下所有知识点的前置依赖边列表。
|
||
* 用于循环检测。
|
||
*/
|
||
export async function getPrerequisiteEdgesForTextbook(
|
||
textbookId: string,
|
||
): Promise<Array<[string, string]>> {
|
||
const rows = await db
|
||
.select({
|
||
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
|
||
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
|
||
})
|
||
.from(knowledgePointPrerequisites)
|
||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId))
|
||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||
.where(eq(chapters.textbookId, textbookId))
|
||
|
||
return rows.map((r) => [r.knowledgePointId, r.prerequisiteKpId] as [string, string])
|
||
}
|