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 => { 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`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 => { 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`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 => { 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 { 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 { 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 { await db.delete(textbooks).where(eq(textbooks.id, id)) } export async function createChapter(data: CreateChapterInput): Promise { 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 { 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 { 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() 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() 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 => { 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 => { 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 { 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 { 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 { await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)) } export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise { 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 => { 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 { 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 { 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 { 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 => { 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`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 => { 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 { await db.insert(knowledgePointPrerequisites).values({ knowledgePointId: data.knowledgePointId, prerequisiteKpId: data.prerequisiteKpId, }) } export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise { await db .delete(knowledgePointPrerequisites) .where(and( eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId), eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId), )) } /** * 获取教材下所有知识点的前置依赖边列表。 * 用于循环检测。 */ export async function getPrerequisiteEdgesForTextbook( textbookId: string, ): Promise> { 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]) }