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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user