feat(textbooks): 教材模块审计重构 — 跨模块解耦 + 权限 + i18n + 错误边界 + 纯函数抽取

P0 修复:
- 解耦跨模块 UI 依赖:knowledge-point-dialogs 不再直接 import questions,
  改为 renderQuestionCreator render prop 由页面注入
- 接入 usePermission Hook 替换 canEdit 硬编码
- 全模块 i18n 改造:新增 en/zh-CN 翻译文件,替换所有硬编码文案
- Server Action 资源归属校验:新增 verifyChapterBelongsToTextbook/
  verifyKnowledgePointBelongsToTextbook,在 reorder/update/delete/create 中校验

P1 改进:
- 补齐 Error Boundary:4 个 error.tsx + TextbookSectionErrorBoundary 区块包裹
- 抽取纯函数到 utils.ts/graph-layout.ts/constants.ts 并补单测(26 用例全通过)
- 消除重复组件:删除 knowledge-point-panel/create-knowledge-point-dialog
- 修复类型断言:chapter.children! → 守卫式访问
- 图谱 a11y:添加 role/aria-label/aria-pressed
- 统一删除确认:confirm() → AlertDialog
- 数据范围过滤:getTextbooksWithScope 支持学生端按年级过滤

P2 预留:
- TextbookAnalytics 埋点接口 + Provider + Hook

同步 005 架构数据 JSON:补充 getTextbooksWithScope/verify*/ChapterTreeNode 等
This commit is contained in:
SpecialX
2026-06-22 16:25:59 +08:00
parent 45ee1ae43c
commit 22d3f07fcf
35 changed files with 2043 additions and 792 deletions

View File

@@ -19,57 +19,21 @@ import type {
UpdateKnowledgePointInput,
UpdateTextbookInput,
} from "./schema"
import {
buildChapterTree,
normalizeOptional,
sortChapters,
} from "./utils"
const normalizeOptional = (v: string | null | undefined): string | null => {
const trimmed = v?.trim()
if (!trimmed) return null
return trimmed
}
export { buildChapterTree, normalizeOptional, sortChapters }
const sortChapters = (a: Chapter, b: Chapter): number => {
const ao = a.order ?? 0
const bo = b.order ?? 0
if (ao !== bo) return ao - bo
return a.title.localeCompare(b.title)
}
const buildChapterTree = (rows: Chapter[]): Chapter[] => {
type ChapterNode = Chapter & { children: ChapterNode[] }
const isChapterNode = (n: Chapter): n is ChapterNode =>
Array.isArray(n.children)
const byId = new Map<string, ChapterNode>()
for (const ch of rows) {
byId.set(ch.id, { ...ch, children: [] })
}
const roots: ChapterNode[] = []
for (const ch of byId.values()) {
const pid = ch.parentId
if (pid) {
const parent = byId.get(pid)
if (parent) {
parent.children.push(ch)
} else {
roots.push(ch)
}
} else {
roots.push(ch)
}
}
const sortRecursive = (nodes: ChapterNode[]) => {
nodes.sort(sortChapters)
for (const n of nodes) {
if (isChapterNode(n)) {
sortRecursive(n.children)
}
}
}
sortRecursive(roots)
return roots
/**
* 数据范围过滤参数。
* 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。
*/
export interface TextbookQueryScope {
/** 按年级过滤(学生端传入学生所在年级) */
grade?: string
}
export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
@@ -459,6 +423,147 @@ export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashb
}
})
// ---------------------------------------------------------------------------
// 资源归属校验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 = `%${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
// ---------------------------------------------------------------------------