Files
NextEdu/src/modules/textbooks/data-access.ts
SpecialX 58656da983 feat(textbooks): 知识图谱功能全面重构 — 前置依赖 + dagre 布局 + React Flow 可视化 + 师生双视角
将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。

数据层:新增 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 一致性。
2026-06-23 00:13:03 +08:00

663 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { cache } from "react"
import { and, 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])
}