docs(audit): add audit reports for grades, homework, lesson-preparation, messaging, permissions, question-bank, settings, textbooks
- Add grades-audit-report - Add homework-audit-report and homework-exams-audit-report - Add lesson-preparation-audit-report-v3 and v4 - Add messaging-audit-report - Add permissions-audit-report - Add question-bank-audit-report - Add settings-profile-audit-report-v3 - Add textbooks-audit-report-v3
This commit is contained in:
815
docs/architecture/audit/adaptive-practice-audit-report.md
Normal file
815
docs/architecture/audit/adaptive-practice-audit-report.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# 专项练习(adaptive-practice)模块审计报告
|
||||
|
||||
> **审计日期**:2026-06-25
|
||||
> **审计范围**:`src/modules/adaptive-practice/` 全部文件 + `src/app/(dashboard)/{student,teacher,management/grade}/practice/` 路由 + 跨模块集成点(error-book)
|
||||
> **对标系统**:Khan Academy、IXL Learning、DreamBox、Smartick、学而思网校、猿辅导、作业帮、超星学习通、ClassIn、智学网
|
||||
> **前置文档**:[004 架构影响地图](../004_architecture_impact_map.md#230-adaptive-practice专项练习模块-核心教学链路闭环)、[005 架构数据](../005_architecture_data.json)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布与代码量
|
||||
|
||||
| 文件 | 行数 | 职责 | 规范符合性 |
|
||||
|------|------|------|-----------|
|
||||
| [types.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/types.ts) | 143 | 联合类型定义(PracticeType/PracticeSourceMeta 等) | ✅ |
|
||||
| [schema.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/schema.ts) | 74 | Zod 输入验证 | ✅ |
|
||||
| [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) | 616 | 学生端 CRUD + 自动判分 | ✅ ≤800 |
|
||||
| [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) | 343 | 四种出题策略 | ✅ |
|
||||
| [data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-analytics.ts) | 634 | 教师/年级宏观数据分析 | ⚠️ 接近 800,建议拆分 |
|
||||
| [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) | 267 | 7 个 Server Actions | ✅ |
|
||||
| [components/practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) | 263 | 练习发起器 | ✅ |
|
||||
| [components/practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) | 550 | 答题界面(含 QuestionCard/AnswerInput/AnswerResult/PracticeResultView) | ⚠️ 超 500,需拆分 |
|
||||
| [components/practice-history.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-history.tsx) | 95 | 练习历史列表 | ✅ |
|
||||
| [components/practice-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-stats-cards.tsx) | 73 | 学生端统计卡片 | ✅ |
|
||||
| [components/practice-overview-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-overview-stats-cards.tsx) | 99 | 教师/年级统计卡片 | ✅ |
|
||||
| [components/class-practice-comparison-table.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/class-practice-comparison-table.tsx) | 88 | 班级对比表 | ✅ |
|
||||
| [components/practice-type-breakdown-chart.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-type-breakdown-chart.tsx) | 123 | 类型分布柱状图 | ✅ |
|
||||
| [components/class-knowledge-point-weakness-chart.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/class-knowledge-point-weakness-chart.tsx) | 144 | 知识点薄弱度柱状图 | ✅ |
|
||||
| [components/student-practice-ranking-table.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/student-practice-ranking-table.tsx) | 112 | 学生排名表 | ✅ |
|
||||
| [components/inactive-students-alert.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/inactive-students-alert.tsx) | 64 | 未参与学生提醒 | ✅ |
|
||||
|
||||
### 1.2 路由分布
|
||||
|
||||
| 路由 | 文件 | 角色 |
|
||||
|------|------|------|
|
||||
| `/student/practice` | `page.tsx` + `loading.tsx` + `error.tsx` | student |
|
||||
| `/student/practice/[sessionId]` | `page.tsx` + `loading.tsx` + `error.tsx` | student |
|
||||
| `/teacher/practice` | `page.tsx` + `loading.tsx` + `error.tsx` | teacher / grade_head / teaching_head |
|
||||
| `/management/grade/practice` | `page.tsx` + `loading.tsx` + `error.tsx` | grade_head / teaching_head |
|
||||
| ❌ `/parent/practice` | **缺失** | parent(有 `ADAPTIVE_PRACTICE_READ` 权限但无页面) |
|
||||
|
||||
### 1.3 数据流与依赖关系
|
||||
|
||||
**模块内**:`app/page.tsx` → `modules/adaptive-practice/{actions, data-access, data-access-analytics}` → `shared/{db, lib/auth-guard, types}`
|
||||
|
||||
**跨模块**:
|
||||
- `modules/adaptive-practice/data-access-analytics.ts` → `modules/classes/data-access`(getActiveStudentIdsByClassId / getClassNameById / getClassesByGradeId / getClassIdsByGradeIds / getStudentIdsByClassIds)✅ 合规
|
||||
- `modules/adaptive-practice/data-access-analytics.ts` → `modules/users/data-access`(getUserIdsByGradeId / getUserNamesByIds)✅ 合规
|
||||
- `app/(dashboard)/student/error-book/student-error-book-list-client.tsx` → `modules/adaptive-practice/actions`(createPracticeSessionAction)✅ app 层组合合规
|
||||
- `app/(dashboard)/teacher/practice/page.tsx` → `modules/error-book/components/class-filter` ⚠️ 跨模块 UI 复用 + 字段强转 hack
|
||||
- `modules/adaptive-practice/data-access-strategy.ts` → `shared/db/schema`(questions / questionsToKnowledgePoints / knowledgePointMastery / practiceAnswers)✅ 合规
|
||||
|
||||
### 1.4 架构图同步状态
|
||||
|
||||
`004_architecture_impact_map.md` §2.30 与 `005_architecture_data.json` 的 `adaptivePractice` 节点已完整覆盖:
|
||||
- DB Schema(practiceSessions / practiceAnswers)、Server Actions(7 个)、Data Access、4 种出题策略、教师/年级宏观数据分析
|
||||
- 依赖矩阵、权限点、DataScope 行级权限、自动判分规则
|
||||
|
||||
**架构图遗漏**:
|
||||
- ❌ 未记录 `parent` 角色路由(因为页面本身缺失,架构图未列出 `/parent/practice`)
|
||||
- ❌ 未记录 `error-book` 模块通过 `onStartVariantPractice` props 注入的解耦关系
|
||||
- ❌ 未记录 `practice-starter.tsx`、`practice-session-view.tsx`、`practice-history.tsx` 等组件的 props 接口
|
||||
- ❌ 未记录 `identifyWeakKnowledgePoints` 这个未被调用的导出函数
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 架构与耦合问题
|
||||
|
||||
#### P0-1 跨模块 UI 复用通过字段强转 hack 实现
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [teacher/practice/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/page.tsx) L31-32, L108-116 |
|
||||
| 问题 | 教师练习分析页直接 import `@/modules/error-book/components/class-filter` 和 `@/modules/error-book/types`,并通过字段重命名把 `TeacherClassPracticeOverview` 强转为 `ClassErrorOverview`:`totalErrorItems ← totalSessions`、`averageMasteryRate ← averageAccuracy`、`dueReviewCount: 0`。语义完全错位,"练习数"被当成"错题数"展示。 |
|
||||
| 规则 | 违反"模块间只能通过对方 data-access 通信"和"避免 `as` 断言"。 |
|
||||
| 后果 | 任何一方修改 `ClassErrorOverview` 或 `ClassFilter` 字段都会破坏练习分析页;筛选器 tooltip 显示"错题数"误导用户。 |
|
||||
|
||||
#### P0-2 PracticeStarter 硬编码路由跳转与 Action 直调
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) L20, L82, L129 |
|
||||
| 问题 | 组件直接 `import { createPracticeSessionAction } from "../actions"` 并 `router.push("/student/practice/${sessionId}")`。组件无法被其他角色(如 parent 监督子女练习、teacher 课堂演示)复用。 |
|
||||
| 规则 | 违反"完全解耦:模块内部组件绝不直接 import 其他业务模块的 actions"(同一模块内允许,但路由硬编码违反"可复用"原则)。 |
|
||||
| 后果 | 组件无法跨角色复用;测试需要 mock 整个 Action 模块;路由变更需改组件。 |
|
||||
|
||||
#### P0-3 PracticeSessionView 单文件 550 行,承担 5 个组件职责
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) |
|
||||
| 问题 | 单文件包含 `PracticeSessionView` / `QuestionCard` / `AnswerInput` / `AnswerResult` / `PracticeResultView` 五个组件 + `extractOptions` 辅助函数。`AnswerInput` 内部对 4 种题型的渲染逻辑高度相似却重复编写。 |
|
||||
| 规则 | 违反"React 组件建议 ≤ 500 行"和"最大化复用:识别共用 UI 块抽象为泛型组件"。 |
|
||||
| 后果 | 难以单测、难以独立复用 `QuestionCard`(例如在错题本详情弹窗中预览变式题)。 |
|
||||
|
||||
### 2.2 权限与安全问题
|
||||
|
||||
#### P0-4 后端 completePracticeSession 不校验是否全部题已答
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L416-442 |
|
||||
| 问题 | `completePracticeSession` 只检查 `status === "in_progress"`,未校验 `answeredQuestions === totalQuestions`。前端 `disabled={isPending \|\| answeredCount < total}` 可被绕过,恶意用户可提交未答完的会话为"已完成",污染统计。 |
|
||||
| 规则 | 违反"安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验"。 |
|
||||
| 后果 | 统计数据失真,影响教师宏观数据分析。 |
|
||||
|
||||
#### P0-5 submitPracticeAnswer 不防并发提交
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L320-410 |
|
||||
| 问题 | 检查 `answerRecord.status === "answered"` 后再更新,但中间无事务/行锁。学生快速双击提交按钮可绕过检查,导致同一题被二次判分,`updateSessionStats` 重复累加 `answeredQuestions` 和 `correctCount`。 |
|
||||
| 规则 | 违反"安全性:Server Action 二次校验"。 |
|
||||
| 后果 | 统计数据被双重累加,正确率失真。 |
|
||||
|
||||
#### P0-6 getPracticeSessionsAction 未校验 studentId 与 ctx 的强一致性
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) L31-56 |
|
||||
| 问题 | 当 `ctx.dataScope.type === "all"`(admin)时,可传任意 studentId 查询;admin 角色确实可查任意学生,但 audit 模块规则要求"权限校验需要 parentId 和 studentId 双重校验"。教师角色 `dataScope.type === "class_taught"` 时,未校验 studentId 是否在所教班级学生中,**任何登录教师可查询任意学生练习数据**(只要把 studentId 直接传给 action)。 |
|
||||
| 规则 | 违反"Parent routes must include permission checks with both parentId and studentId to prevent information leakage"。 |
|
||||
| 后果 | 教师越权查看非本班学生练习记录,数据泄露。 |
|
||||
|
||||
### 2.3 国际化遗漏(i18n)
|
||||
|
||||
#### P0-7 error.tsx 大量硬编码中文
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [teacher/practice/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/error.tsx) L16-23, [management/grade/practice/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/management/grade/practice/error.tsx) L16-23 |
|
||||
| 问题 | "专项练习分析"、"加载练习分析数据时发生错误"、"加载失败"、"请刷新页面重试..."、"年级专项练习总览"、"加载年级练习数据时发生错误" 全部硬编码中文。 |
|
||||
| 规则 | 违反"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。 |
|
||||
| 后果 | 英文环境下显示中文,破坏国际化。 |
|
||||
|
||||
#### P0-8 actions.ts 错误消息硬编码中文
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) L53, L77, L129, L137, L154, L161, L178, L184, L204, L235, L263 |
|
||||
| 问题 | Server Action 返回的 `message` 字段硬编码中文:"获取练习列表失败"、"练习会话不存在或无权访问"、"提交格式错误"、"输入验证失败"、"未找到符合条件的题目"、"已创建练习会话"、"已跳过此题"、"回答正确"、"回答错误"、"练习已完成"、"练习已放弃" 等。这些消息通过 ActionState 返回到前端 toast 展示给用户。 |
|
||||
| 规则 | 违反"所有用户可见文本必须适配 i18n"。 |
|
||||
| 后果 | 英文用户看到中文 toast。 |
|
||||
|
||||
#### P0-9 data-access.ts 抛出错误消息硬编码中文
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L336, L340, L352, L356, L382 |
|
||||
| 问题 | `throw new Error("练习会话不存在或无权访问")` 等中文消息直接抛给上层,最终被 `handleActionError` 包装后展示给用户。 |
|
||||
| 规则 | 违反"所有用户可见文本必须适配 i18n"。 |
|
||||
| 后果 | 与 P0-8 同。 |
|
||||
|
||||
#### P0-10 业务数据写入翻译文本
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) L113-117 |
|
||||
| 问题 | `sourceMeta = { recommendedKnowledgePointIds, reason: t("toasts.aiRecommendedReason") }` —— 把翻译文本作为业务数据写入数据库 `practice_sessions.source_meta.reason` 字段。语言切换后历史记录的 reason 不一致;数据库存储多语言文本违反数据归一化。 |
|
||||
| 规则 | 违反"业务数据不应包含翻译文本"(架构规范)。 |
|
||||
| 后果 | 数据库冗余、语言切换不一致、跨语言环境数据污染。 |
|
||||
|
||||
### 2.4 类型安全问题
|
||||
|
||||
#### P1-1 data-access.ts 多处 `as` 类型断言绕过严格模式
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L38 `row.practiceType as PracticeType`、L39 `row.status as PracticeStatus`、L60 `row.status as PracticeAnswerStatus`、L174 `session.sourceMeta as PracticeSourceMeta \| null`、L217 `practiceType: type as PracticeType` |
|
||||
| 问题 | 从 DB 取出的 enum 字段直接 `as` 断言,未通过类型守卫校验。如果 DB 数据被脏写(如手工改库),运行时会把无效值当作合法值处理。 |
|
||||
| 规则 | 违反"禁止 `as` 断言(除类型收窄外)",且"未知类型用 `unknown` 并做类型守卫"。 |
|
||||
| 后果 | 类型系统失效,潜在运行时错误。 |
|
||||
|
||||
#### P1-2 actions.ts L144 双重 as 断言
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) L144 `parsed.data.sourceMeta as unknown as PracticeSourceMeta` |
|
||||
| 问题 | `z.record(z.string(), z.unknown())` 返回 `Record<string, unknown>`,通过 `as unknown as` 双重断言绕过类型系统。Zod schema 没有按 PracticeSourceMeta 联合类型做判别式校验。 |
|
||||
| 规则 | 违反"禁止 `as` 断言"。 |
|
||||
| 后果 | 客户端可构造任意结构的 sourceMeta 写入数据库,data-access-strategy 中的类型守卫只检查 key 存在性,不检查 value 类型。 |
|
||||
|
||||
#### P1-3 data-access-strategy.ts 类型守卫不充分
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L325-343 |
|
||||
| 问题 | 类型守卫仅检查 `"errorBookItemIds" in meta` 等 key 存在性,不验证 `errorBookItemIds` 是 `string[]`、`sourceQuestionIds` 是 `string[]`。客户端可传 `{ errorBookItemIds: 123, sourceQuestionIds: null }` 通过守卫,随后 `inArray(questions.id, sourceQuestionIds)` 抛 SQL 错误。 |
|
||||
| 规则 | 违反"未知类型用 `unknown` 并做类型守卫"。 |
|
||||
| 后果 | SQL 异常泄露内部信息。 |
|
||||
|
||||
#### P1-4 practice-session-view.tsx L360 数组断言
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) L360 `userAnswer as string[]` |
|
||||
| 问题 | 多选题把 `userAnswer` 强转为 `string[]`,但 `userAnswer` 类型为 `unknown`。 |
|
||||
| 规则 | 违反"禁止 `as` 断言"。 |
|
||||
| 后果 | 类型不安全。 |
|
||||
|
||||
### 2.5 业务逻辑缺陷
|
||||
|
||||
#### P0-11 "错题变式"策略实际不做变式
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L40-70 |
|
||||
| 问题 | 函数名 `selectForErrorVariant`,类型定义 `QuestionSelectionResult.variants: Map<string, unknown>`,但实现直接查询原题返回,`variants: new Map()` 始终为空。注释 L34 写明"不依赖 AI 生成变式题",但**对外仍以"错题变式"命名**,UI 上展示为"错题变式"练习类型。功能与名称严重不符。 |
|
||||
| 规则 | 违反"组件必须为纯函数,使用 `function` 声明"中的语义诚实原则。 |
|
||||
| 后果 | 用户期待"变式题"实际是原题重做,体验落差;与 AI 模块定义的 `AiQuestionVariantGenerator` 能力割裂。 |
|
||||
|
||||
#### P0-12 "薄弱章节"策略未自动识别薄弱知识点
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L135-180, L298-319 |
|
||||
| 问题 | `selectForWeakChapter` 接收 `sourceMeta.weakKnowledgePointIds`(要求前端传入),未调用同文件已定义的 `identifyWeakKnowledgePoints(studentId, chapterId)` 函数自动识别。`WeakChapterSourceMeta.chapterId` 字段定义了但策略中未使用。用户必须先在另一处查看薄弱知识点再手动选择,体验割裂。 |
|
||||
| 规则 | 违反"组合优先:逻辑复用一律抽取为自定义 hooks"和"最大化复用"。 |
|
||||
| 后果 | "薄弱章节"功能名不副实;`identifyWeakKnowledgePoints` 成为死代码。 |
|
||||
|
||||
#### P0-13 createPracticeSession 返回空 sessionId 表示失败
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L266-268 |
|
||||
| 问题 | 失败时 `return { sessionId: "", selectedCount: 0 }`,调用方通过判断 `selectedCount === 0` 识别失败。空字符串作为 ID 是反模式,与成功的 `{ sessionId: "xxx", selectedCount: 0 }`(理论上可能)混淆。 |
|
||||
| 规则 | 违反"函数返回值必须显式标注"和"明确处理边界状态"。 |
|
||||
| 后果 | 调用方判断逻辑脆弱;后续重构易引入 bug。 |
|
||||
|
||||
#### P1-5 出题策略使用 `ORDER BY RAND()` 性能差
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L116, L173, L225 |
|
||||
| 问题 | 知识点专项、薄弱章节、AI 推荐三种策略都用 `sql\`RAND()\``。MySQL `ORDER BY RAND()` 在大表上会全表扫描排序,题库上万题时性能急剧下降。 |
|
||||
| 规则 | 违反"性能:优先使用 React Server Components 获取初始数据"。 |
|
||||
| 后果 | 大题库下出题延迟可达数秒。 |
|
||||
|
||||
#### P1-6 getTeacherClassPracticeOverviews / getGradeClassPracticeComparison N+1 查询
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-analytics.ts) L276-340, L432-496 |
|
||||
| 问题 | `Promise.all(classIds.map(...))` 内部每个班级至少 3 次 DB 查询(getClassNameById + getActiveStudentIdsByClassId + 2 次 practiceSessions 聚合)。10 个班级 = 30 次查询。 |
|
||||
| 规则 | 违反"性能:优先使用 RSC"和工程规范"批量查询应合并"。 |
|
||||
| 后果 | 班级多时延迟累积。 |
|
||||
|
||||
### 2.6 错误处理与边界缺失
|
||||
|
||||
#### P0-14 答题提交失败后无重试机制
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) L81-107 |
|
||||
| 问题 | `handleSubmit` 失败仅 `toast.error`,不保留失败状态、不提供重试按钮。学生网络抖动时需要手动重新选择答案再提交,且因为 `setResults` 未更新,UI 上仍显示"未作答"。 |
|
||||
| 规则 | 违反"明确处理空数据、无权限、网络异常等边界状态"。 |
|
||||
| 后果 | 网络异常时学生困惑、流失答题意愿。 |
|
||||
|
||||
#### P0-15 缺少细粒度 React Error Boundary
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [teacher/practice/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/page.tsx) 全文 |
|
||||
| 问题 | 教师分析页一个数据区块失败会导致整页回退到 `error.tsx`。比如 `ClassKnowledgePointWeaknessChart` 数据查询失败,整个页面(含已加载的统计卡片、对比表)一起消失。 |
|
||||
| 规则 | 违反"每个独立的数据区块必须用 React Error Boundary 包裹"。 |
|
||||
| 后果 | 局部错误导致整页不可用。 |
|
||||
|
||||
#### P0-16 缺少流式渲染与骨架屏细粒度
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [teacher/practice/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/page.tsx) L122-133 |
|
||||
| 问题 | `Promise.all([...])` 阻塞所有数据加载完成才渲染任何内容,仅外层 `Suspense` 包裹整页。无区块级 Suspense + 骨架屏。 |
|
||||
| 规则 | 违反"异步数据使用 React Suspense + 骨架屏"和"支持流式渲染"。 |
|
||||
| 后果 | 首屏白屏时间长达数秒。 |
|
||||
|
||||
### 2.7 可测试性缺失
|
||||
|
||||
#### P1-7 自动判分纯函数未导出
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L517-616 |
|
||||
| 问题 | `autoGradeAnswer` / `extractChoiceCorrectIds` / `extractJudgmentCorrectAnswer` / `normalizeAnswerToIds` / `normalizeAnswerToBool` 全部为内部函数,未 `export`。无法单测,判分正确性无保障。 |
|
||||
| 规则 | 违反"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock"。 |
|
||||
| 后果 | 判分 bug 难以回归。 |
|
||||
|
||||
#### P1-8 出题策略未单独导出
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L40-232 |
|
||||
| 问题 | `selectForErrorVariant` / `selectForKnowledgePoint` / `selectForWeakChapter` / `selectForAiRecommended` 均未导出。仅 `selectQuestionsForPractice` 入口可测,无法针对单策略测试。 |
|
||||
| 规则 | 违反"导出清晰的接口类型以便 mock"。 |
|
||||
| 后果 | 策略调整需要端到端测试。 |
|
||||
|
||||
### 2.8 可访问性(a11y)缺失
|
||||
|
||||
#### P1-9 题目内容用 JSON.stringify 展示
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) L287-293 |
|
||||
| 问题 | 题目内容若不是字符串,直接 `JSON.stringify(content, null, 2)` 渲染在 `<pre>` 中。学生看到 `{"options":[{"id":"a","text":"..."}]}` 这种 JSON 而非可读题目。 |
|
||||
| 规则 | 违反"a11y:语义化标签、ARIA 属性、键盘导航"。 |
|
||||
| 后果 | 用户体验极差,无法正常答题。 |
|
||||
|
||||
#### P1-10 自定义 checkbox 缺少 aria-label
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | [practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) L192-204 |
|
||||
| 问题 | `<input type="checkbox">` 原生元素而非 shadcn `Checkbox`,且无 `aria-label`。屏幕阅读器无法识别知识点名称。 |
|
||||
| 规则 | 违反"a11y:ARIA 属性"。 |
|
||||
| 后果 | 视障用户无法使用。 |
|
||||
|
||||
### 2.9 Parent 角色路由缺失
|
||||
|
||||
#### P0-17 parent 有权限无页面
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 位置 | 缺失 `src/app/(dashboard)/parent/practice/` |
|
||||
| 问题 | `parent` 角色在 `rolePermissions` 中拥有 `ADAPTIVE_PRACTICE_READ`,actions.ts 已实现 `ctx.dataScope.type === "children"` 分支,但无 `/parent/practice` 路由。家长无法查看子女练习记录、统计、错题变式入口。 |
|
||||
| 规则 | 违反"Parent routes must include permission checks with both parentId and studentId"。 |
|
||||
| 后果 | 家长无法监督子女学习,与 parent/error-book 等同级模块功能不对等。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与 Khan Academy / IXL Learning 的差距
|
||||
|
||||
| 差距项 | 现状 | 行业实践 | 影响 |
|
||||
|--------|------|---------|------|
|
||||
| **掌握度驱动的自适应** | 仅 4 种静态出题策略,掌握度(knowledgePointMastery 表)仅在 weak_chapter 策略中可选用 | Khan Academy 的 Learning Dashboard 持续追踪掌握度,根据答对率自动调整下一题难度(IRT 自适应) | 学生无法获得"刚好难一点"的最近发展区练习 |
|
||||
| **学习路径可视化** | 仅会话列表 + 统计卡片 | Khan Academy 的 World of Math 知识图谱节点着色显示掌握度 | 学生无法看到知识结构全貌,缺乏长期目标感 |
|
||||
| **即时反馈与解析** | 仅显示"正确/错误",无解析 | IXL 每题答错立即展示完整解析与同类练习推荐 | 学生不知道为什么错,无法从错误中学习 |
|
||||
| **连续练习激励机制** | 无 | Khan Academy 的 Streak(连续天数)、Energy Points、Badges | 学生缺乏持续练习动力 |
|
||||
|
||||
### 3.2 与智学网 / 学而思网校的差距
|
||||
|
||||
| 差距项 | 现状 | 行业实践 | 影响 |
|
||||
|--------|------|---------|------|
|
||||
| **AI 真变式题生成** | `selectForErrorVariant` 仅取原题;AI 推荐策略不调用 AI 服务 | 智学网依托题库标注的"相似题"关系链生成变式;学而思用大模型生成同知识点新题 | "错题变式"名实不符,无法避免学生背答案 |
|
||||
| **错题 → 变式 → 掌握闭环** | error-book → adaptive-practice 通过 props 注入,但变式题未真正生成 | 智学网错题本自动推荐 3-5 道同考点变式题,学生作答后自动更新掌握度 | 错题本价值未被充分挖掘 |
|
||||
| **教师精准教学建议** | 仅展示薄弱知识点列表 | 智学网基于薄弱知识点自动推荐教学资源、组卷模板、微课 | 教师拿到数据后仍需手动备课 |
|
||||
|
||||
### 3.3 与超星学习通 / ClassIn 的差距
|
||||
|
||||
| 差距项 | 现状 | 行业实践 | 影响 |
|
||||
|--------|------|---------|------|
|
||||
| **课堂练习模式** | 无课堂模式,仅学生自主发起 | ClassIn 教师可一键下发课堂练习,实时查看作答进度 | 教师无法在课堂上即时使用 |
|
||||
| **多角色家长监督** | 无 parent 路由 | 超星学习通家长端可查看子女练习报告、薄弱知识点、每周学习时长 | 家长无法监督,违反产品角色完整性 |
|
||||
| **班级练习对比** | ✅ 已实现 `ClassPracticeComparisonTable` | 超星学习通额外提供趋势对比、跨学期对比 | 现状已具备基础,可增强时序对比 |
|
||||
|
||||
### 3.4 关键交互差距
|
||||
|
||||
| 差距项 | 现状 | 行业实践 |
|
||||
|--------|------|---------|
|
||||
| **题目内容渲染** | JSON.stringify 兜底 | 标准化题型组件库(单选/多选/判断/填空/简答),富文本+公式+图片 |
|
||||
| **答题进度本地持久化** | 仅 useState,刷新丢失 | localStorage 暂存未提交答案 |
|
||||
| **离线模式** | 无 | 移动端弱网下缓存题目,联网同步 |
|
||||
| **练习报告导出** | 无 | PDF 导出给家长签字 |
|
||||
| **错题复盘提醒** | 无 | 间隔重复(SM2)算法驱动复习提醒(error-book 已有 SM2,但未联动) |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响数据正确性、安全、核心功能)
|
||||
|
||||
| 编号 | 改进方向 | 涉及问题 |
|
||||
|------|---------|---------|
|
||||
| P0-修复-1 | 后端 `completePracticeSession` 增加答题完整性校验;`submitPracticeAnswer` 增加事务/行锁防并发 | P0-4, P0-5 |
|
||||
| P0-修复-2 | `getPracticeSessionsAction` / `getPracticeSessionDetailAction` / `getPracticeStatsAction` 增加 `class_taught` / `grade_managed` dataScope 下 studentId 归属校验(基于 `getStudentIdsByClassIds` 比对) | P0-6 |
|
||||
| P0-修复-3 | 提取 `shared/components/practice-class-filter` 替代跨模块复用 error-book 的 ClassFilter,移除字段强转 hack | P0-1 |
|
||||
| P0-修复-4 | `selectForErrorVariant` 接入 AI 变式题生成(通过依赖注入 `QuestionVariantGenerator` 接口),或重命名为"错题重做"消除名实不符 | P0-11 |
|
||||
| P0-修复-5 | `selectForWeakChapter` 调用 `identifyWeakKnowledgePoints(studentId, chapterId)` 自动识别薄弱知识点;删除前端必填 `weakKnowledgePointIds` 的硬约束 | P0-12 |
|
||||
| P0-修复-6 | `createPracticeSession` 失败抛 `PracticeQuestionNotFoundError` 而非返回空 sessionId | P0-13 |
|
||||
| P0-修复-7 | 全量提取 i18n:error.tsx、actions.ts、data-access.ts 中的硬编码中文;翻译键结构见 §五重构方案 | P0-7, P0-8, P0-9 |
|
||||
| P0-修复-8 | `practice-starter.tsx` 移除 `reason: t("toasts.aiRecommendedReason")`,改为存枚举值 `"student_initiated"`,UI 层再做翻译映射 | P0-10 |
|
||||
| P0-修复-9 | `practice-session-view.tsx` 拆分为 `practice-session-view.tsx` + `question-card.tsx` + `answer-input.tsx` + `answer-result.tsx` + `practice-result-view.tsx`;引入 `QuestionRenderer`(复用 homework 模块同款)替代 JSON.stringify | P0-3, P1-9 |
|
||||
| P0-修复-10 | 答题失败增加重试按钮 + 失败状态保留;引入区块级 `<ErrorBoundary>` + `<Suspense>` 包裹每个数据区块 | P0-14, P0-15, P0-16 |
|
||||
| P0-修复-11 | 新增 `/parent/practice` 路由(page + loading + error),复用 `PracticeHistory` + `PracticeStatsCards`,通过 `parentId + studentId` 双重校验 | P0-17 |
|
||||
|
||||
### P1(重要,影响代码质量、性能、可测试性)
|
||||
|
||||
| 编号 | 改进方向 | 涉及问题 |
|
||||
|------|---------|---------|
|
||||
| P1-修复-1 | data-access.ts 用类型守卫 `isPracticeType` / `isPracticeStatus` 替换 `as` 断言;schema.ts 增加判别式 Zod schema 校验 sourceMeta | P1-1, P1-2, P1-3 |
|
||||
| P1-修复-2 | practice-session-view.tsx L360 改用 `Array.isArray(userAnswer) && userAnswer.every(v => typeof v === "string")` 类型守卫 | P1-4 |
|
||||
| P1-修复-3 | 出题策略改用 `ORDER BY questions.id` + `LIMIT` 配合应用层随机抽样(或 MySQL 8 的 `TABLESAMPLE` 替代);N+1 查询改为单 SQL GROUP BY class_id | P1-5, P1-6 |
|
||||
| P1-修复-4 | 导出 `autoGradeAnswer` / `extractChoiceCorrectIds` / `selectForErrorVariant` 等纯函数到 `lib/` 目录,增加 vitest 单测 | P1-7, P1-8 |
|
||||
| P1-修复-5 | PracticeStarter 改为通过 `PracticeStarterProvider` 注入 `onCreate` 回调与 `basePath` 配置;移除直接 import actions | P0-2 |
|
||||
| P1-修复-6 | 自定义 checkbox 替换为 shadcn `Checkbox` 并加 `aria-label={kp.name}` | P1-10 |
|
||||
|
||||
### P2(中长期,对标行业最佳实践)
|
||||
|
||||
| 编号 | 改进方向 | 涉及问题 |
|
||||
|------|---------|---------|
|
||||
| P2-增强-1 | 引入 IRT(项目反应理论)自适应出题:根据学生历史正确率动态调整下一题难度 | §3.1 |
|
||||
| P2-增强-2 | 答题后展示解析 + 推荐同类练习(联动 questions 模块的相似题关系链) | §3.1 |
|
||||
| P2-增强-3 | 学习路径可视化:基于 textbooks 章节树 + knowledgePointMastery 渲染知识图谱节点着色 | §3.1 |
|
||||
| P2-增强-4 | 连续练习激励:Streak / Energy Points / Badges,存 users 表扩展字段 | §3.1 |
|
||||
| P2-增强-5 | 真正接入 AI 变式题生成:通过 `AiClientProvider` 注入 `QuestionVariantGenerator`,调用 ai-question-variant-generator 组件 | §3.2 |
|
||||
| P2-增强-6 | 间隔重复复习提醒:联动 error-book 的 SM2 算法,到期错题自动出现在"错题变式"入口 | §3.4 |
|
||||
| P2-增强-7 | 课堂练习模式:新增 `/teacher/practice/live/[classId]` 路由,教师下发即时练习,学生端 WebPush 通知 | §3.3 |
|
||||
| P2-增强-8 | 练习报告 PDF 导出:服务端生成 PDF 供家长签字 | §3.4 |
|
||||
| P2-增强-9 | 答题进度 localStorage 持久化:刷新不丢未提交答案 | §3.4 |
|
||||
| P2-增强-10 | data-access-analytics.ts 拆分为 `data-access-analytics-class.ts`(班级维度)+ `data-access-analytics-grade.ts`(年级维度)+ `data-access-analytics-shared.ts`(共享类型与工具) | §1.1 |
|
||||
|
||||
---
|
||||
|
||||
## 五、重构方案设计
|
||||
|
||||
### 5.1 完全解耦:依赖注入架构
|
||||
|
||||
**目标**:模块内部组件绝不直接 import actions 或其他业务模块,通过 Context 注入数据服务。
|
||||
|
||||
#### 5.1.1 定义数据服务接口(`services/practice-service.ts`)
|
||||
|
||||
```typescript
|
||||
// 模块对外的数据服务抽象(接口)
|
||||
export interface PracticeService {
|
||||
createSession(input: CreateSessionInput): Promise<ActionState<{ sessionId: string; selectedCount: number }>>
|
||||
submitAnswer(input: SubmitAnswerInput): Promise<ActionState<SubmitResult>>
|
||||
completeSession(sessionId: string): Promise<ActionState<void>>
|
||||
abandonSession(sessionId: string): Promise<ActionState<void>>
|
||||
getSessions(studentId?: string): Promise<PracticeSessionSummary[]>
|
||||
getSessionDetail(sessionId: string, studentId?: string): Promise<PracticeSessionDetail | null>
|
||||
getStats(studentId?: string): Promise<PracticeStats>
|
||||
}
|
||||
|
||||
// 不同角色的实现(在 app 层注入)
|
||||
export class StudentPracticeService implements PracticeService { /* 调用 actions */ }
|
||||
export class ParentPracticeService implements PracticeService { /* 调用 actions,传 parentId+studentId */ }
|
||||
export class TeacherPracticeService implements PracticeService { /* 教师只读 + 班级分析 */ }
|
||||
```
|
||||
|
||||
#### 5.1.2 Context Provider(`context/practice-service-provider.tsx`)
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
const PracticeServiceContext = createContext<PracticeService | null>(null)
|
||||
|
||||
export function PracticeServiceProvider({ service, children }: {
|
||||
service: PracticeService
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <PracticeServiceContext.Provider value={service}>{children}</PracticeServiceContext.Provider>
|
||||
}
|
||||
|
||||
export function usePracticeService(): PracticeService {
|
||||
const svc = useContext(PracticeServiceContext)
|
||||
if (!svc) throw new Error("PracticeServiceProvider missing")
|
||||
return svc
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.1.3 app 层注入
|
||||
|
||||
```tsx
|
||||
// app/(dashboard)/student/practice/page.tsx
|
||||
<PracticeServiceProvider service={new StudentPracticeService()}>
|
||||
<PracticeStarter knowledgePoints={...} />
|
||||
<PracticeHistory sessions={...} />
|
||||
</PracticeServiceProvider>
|
||||
|
||||
// app/(dashboard)/parent/practice/page.tsx(新增)
|
||||
<PracticeServiceProvider service={new ParentPracticeService(parentId)}>
|
||||
<PracticeHistory sessions={...} studentId={childId} />
|
||||
<PracticeStatsCards stats={...} />
|
||||
</PracticeServiceProvider>
|
||||
```
|
||||
|
||||
### 5.2 组合优先:组件拆分与组合
|
||||
|
||||
#### 5.2.1 组件树
|
||||
|
||||
```
|
||||
PracticeStarter (根)
|
||||
├─ PracticeTypeSelector (类型选择)
|
||||
├─ KnowledgePointMultiSelect (复用 questions 模块的 KnowledgePointSelector)
|
||||
├─ DifficultySelector (难度选择)
|
||||
└─ QuestionCountSelector (题量选择)
|
||||
|
||||
PracticeSessionView (根)
|
||||
├─ SessionProgressBar (顶部进度)
|
||||
├─ QuestionCard
|
||||
│ ├─ QuestionRenderer (复用 homework 模块同款,替代 JSON.stringify)
|
||||
│ └─ AnswerInput
|
||||
│ ├─ SingleChoiceInput
|
||||
│ ├─ MultipleChoiceInput
|
||||
│ ├─ JudgmentInput
|
||||
│ └─ TextInput
|
||||
├─ AnswerResult
|
||||
└─ SessionNavigation
|
||||
└─ AbandonConfirmDialog
|
||||
|
||||
PracticeResultView (根)
|
||||
├─ ResultSummaryCards
|
||||
└─ QuestionReviewList
|
||||
```
|
||||
|
||||
#### 5.2.2 自定义 hooks 抽取
|
||||
|
||||
```typescript
|
||||
// hooks/use-practice-session.ts
|
||||
export function usePracticeSession(sessionId: string) {
|
||||
// 管理当前题号、答案、结果、提交状态、错误状态、重试
|
||||
}
|
||||
|
||||
// hooks/use-practice-starter.ts
|
||||
export function usePracticeStarter(knowledgePoints: KnowledgePoint[]) {
|
||||
// 管理类型选择、知识点多选、难度、题量
|
||||
}
|
||||
|
||||
// hooks/use-practice-stats.ts (教师/年级)
|
||||
export function usePracticeStats(classId: string) {
|
||||
// 管理班级筛选、统计数据缓存
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 国际化就绪
|
||||
|
||||
#### 5.3.1 翻译文件结构(`messages/zh-CN/practice.json` 扩展)
|
||||
|
||||
```json
|
||||
{
|
||||
"page": { "title": "...", "description": "..." },
|
||||
"starter": { ... },
|
||||
"session": { ... },
|
||||
"result": { ... },
|
||||
"history": { ... },
|
||||
"toasts": { ... },
|
||||
"stats": { ... },
|
||||
"types": { ... },
|
||||
"status": { ... },
|
||||
"teacher": { ... },
|
||||
"grade": { ... },
|
||||
"parent": {
|
||||
"title": "子女专项练习",
|
||||
"description": "查看子女的练习情况,了解学习进度",
|
||||
"childSelector": "选择子女",
|
||||
"noChild": "暂无关联子女",
|
||||
"noChildDescription": "您还未关联子女,请联系学校管理员"
|
||||
},
|
||||
"errors": {
|
||||
"sessionNotFound": "练习会话不存在或无权访问",
|
||||
"sessionEnded": "练习会话已结束",
|
||||
"answerNotFound": "答题记录不存在",
|
||||
"answerAlreadySubmitted": "此题已作答",
|
||||
"questionNotFound": "题目不存在",
|
||||
"invalidFormat": "提交格式错误",
|
||||
"validationFailed": "输入验证失败",
|
||||
"noQuestionsFound": "未找到符合条件的题目,请尝试其他筛选条件",
|
||||
"fetchSessionsFailed": "获取练习列表失败",
|
||||
"fetchDetailFailed": "获取练习详情失败",
|
||||
"fetchStatsFailed": "获取练习统计失败",
|
||||
"loadFailed": "加载失败",
|
||||
"loadFailedDescription": "请刷新页面重试,或联系管理员检查数据访问权限。",
|
||||
"pageErrorPractice": "加载练习分析数据时发生错误",
|
||||
"pageErrorGrade": "加载年级练习数据时发生错误"
|
||||
},
|
||||
"messages": {
|
||||
"sessionCreated": "已创建练习会话,共 {count} 道题目",
|
||||
"answerSubmitted": "答案已提交",
|
||||
"answerCorrect": "回答正确",
|
||||
"answerIncorrect": "回答错误",
|
||||
"answerSkipped": "已跳过此题",
|
||||
"sessionCompleted": "练习已完成",
|
||||
"sessionAbandoned": "练习已放弃"
|
||||
},
|
||||
"reasons": {
|
||||
"student_initiated": "学生自主发起 AI 推荐练习",
|
||||
"teacher_assigned": "教师布置",
|
||||
"parent_suggested": "家长建议"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3.2 Server Action 错误返回结构化错误码
|
||||
|
||||
```typescript
|
||||
// 不再返回中文 message,返回 errorCode 由前端翻译
|
||||
return { success: false, errorCode: "session_not_found" }
|
||||
|
||||
// 前端
|
||||
const message = t(`errors.${res.errorCode}`)
|
||||
```
|
||||
|
||||
### 5.4 最大化复用:泛型组件与配置驱动
|
||||
|
||||
#### 5.4.1 角色配置驱动渲染
|
||||
|
||||
```typescript
|
||||
// config/role-config.ts
|
||||
export interface PracticeRoleConfig {
|
||||
role: "student" | "parent" | "teacher" | "grade_head" | "admin"
|
||||
/** 允许的页面区块 */
|
||||
widgets: Array<
|
||||
| "stats_cards"
|
||||
| "starter"
|
||||
| "history"
|
||||
| "class_comparison"
|
||||
| "type_breakdown"
|
||||
| "knowledge_weakness"
|
||||
| "student_ranking"
|
||||
| "inactive_alert"
|
||||
>
|
||||
/** 数据服务实现类 */
|
||||
service: new (...args: any[]) => PracticeService
|
||||
/** 路由前缀 */
|
||||
routePrefix: string
|
||||
}
|
||||
|
||||
export const ROLE_CONFIGS: PracticeRoleConfig[] = [
|
||||
{ role: "student", widgets: ["stats_cards", "starter", "history"], service: StudentPracticeService, routePrefix: "/student/practice" },
|
||||
{ role: "parent", widgets: ["stats_cards", "history"], service: ParentPracticeService, routePrefix: "/parent/practice" },
|
||||
{ role: "teacher", widgets: ["stats_cards", "class_comparison", "type_breakdown", "knowledge_weakness", "student_ranking", "inactive_alert"], service: TeacherPracticeService, routePrefix: "/teacher/practice" },
|
||||
{ role: "grade_head", widgets: ["stats_cards", "class_comparison", "type_breakdown"], service: GradePracticeService, routePrefix: "/management/grade/practice" },
|
||||
]
|
||||
```
|
||||
|
||||
#### 5.4.2 通用统计卡片泛型组件
|
||||
|
||||
```tsx
|
||||
// shared/components/stats-card.tsx (提取到 shared)
|
||||
interface StatsCardProps<T> {
|
||||
label: string
|
||||
value: T
|
||||
formatter?: (v: T) => string
|
||||
icon: LucideIcon
|
||||
color?: string
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 错误与边界处理
|
||||
|
||||
#### 5.5.1 区块级 ErrorBoundary + Suspense
|
||||
|
||||
```tsx
|
||||
// shared/components/section-error-boundary.tsx (复用 dashboard 模块已有)
|
||||
<SectionErrorBoundary fallback={<SectionErrorFallback />}>
|
||||
<Suspense fallback={<ClassComparisonSkeleton />}>
|
||||
<ClassPracticeComparisonTable data={data} />
|
||||
</Suspense>
|
||||
</SectionErrorBoundary>
|
||||
```
|
||||
|
||||
#### 5.5.2 答题失败重试
|
||||
|
||||
```tsx
|
||||
const [submitError, setSubmitError] = useState<Error | null>(null)
|
||||
|
||||
async function handleSubmit(answer: unknown) {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const res = await svc.submitAnswer(...)
|
||||
if (!res.success) throw new Error(res.errorCode)
|
||||
} catch (e) {
|
||||
setSubmitError(e as Error)
|
||||
// 保留 selectedAnswer,UI 显示重试按钮
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染
|
||||
{submitError ? (
|
||||
<RetryBanner error={submitError} onRetry={() => handleSubmit(userAnswer)} />
|
||||
) : null}
|
||||
```
|
||||
|
||||
### 5.6 可测试性
|
||||
|
||||
#### 5.6.1 纯函数抽取(`lib/grading.ts`、`lib/source-meta.ts`)
|
||||
|
||||
```typescript
|
||||
// lib/grading.ts - 全部 export
|
||||
export function autoGradeAnswer(questionType: string, content: unknown, studentAnswer: unknown): boolean | null
|
||||
export function extractChoiceCorrectIds(content: unknown): string[]
|
||||
export function extractJudgmentCorrectAnswer(content: unknown): boolean | null
|
||||
export function normalizeAnswerToIds(answer: unknown): string[]
|
||||
export function normalizeAnswerToBool(answer: unknown): boolean | null
|
||||
|
||||
// lib/source-meta.ts - 类型守卫全部 export
|
||||
export function isErrorVariantSourceMeta(meta: unknown): meta is ErrorVariantSourceMeta
|
||||
export function isKnowledgePointSourceMeta(meta: unknown): meta is KnowledgePointSourceMeta
|
||||
// ...
|
||||
|
||||
// lib/strategy.ts - 策略函数 export
|
||||
export async function selectForErrorVariant(...)
|
||||
```
|
||||
|
||||
#### 5.6.2 单测示例(`lib/grading.test.ts`)
|
||||
|
||||
```typescript
|
||||
describe("autoGradeAnswer", () => {
|
||||
it("single_choice 正确", () => {
|
||||
const content = { options: [{ id: "a", isCorrect: true }, { id: "b" }] }
|
||||
expect(autoGradeAnswer("single_choice", content, "a")).toBe(true)
|
||||
})
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
### 5.7 可扩展性:配置驱动
|
||||
|
||||
新增角色或功能只需修改 `config/role-config.ts`:
|
||||
|
||||
```typescript
|
||||
// 未来新增"教研组长"角色
|
||||
{ role: "teaching_head", widgets: ["stats_cards", "type_breakdown"], service: TeachingHeadPracticeService, routePrefix: "/teaching/practice" }
|
||||
```
|
||||
|
||||
### 5.8 企业级补充
|
||||
|
||||
#### 5.8.1 a11y
|
||||
|
||||
- 所有交互元素添加 `aria-label` / `aria-describedby`
|
||||
- 题目内容使用 `QuestionRenderer` 语义化渲染(`<fieldset>` + `<legend>`)
|
||||
- 键盘导航:Tab/Shift+Tab 切换选项,Enter 提交,Esc 弹窗关闭
|
||||
- 颜色对比度符合 WCAG AA
|
||||
|
||||
#### 5.8.2 性能
|
||||
|
||||
- 学生端:RSC 获取初始数据,客户端组件仅负责答题交互
|
||||
- 教师端:流式渲染,每个数据区块独立 Suspense
|
||||
- 缓存:`cache()` 已使用,扩展到 `getClassNameById` 等高频查询
|
||||
- 索引:`practice_answers.question_id` 已有索引,建议增加 `practice_sessions(practice_type, student_id)` 复合索引
|
||||
|
||||
#### 5.8.3 安全性
|
||||
|
||||
- data-access 层所有查询结合 `ctx.userId` / `ctx.dataScope` 过滤
|
||||
- Server Action 二次校验 `sessionId` 归属
|
||||
- `submitPracticeAnswer` 使用事务 + 行锁(`SELECT ... FOR UPDATE`)
|
||||
|
||||
#### 5.8.4 监控埋点
|
||||
|
||||
```typescript
|
||||
// shared/lib/track.ts
|
||||
track("practice_session_created", { practiceType, questionCount, role })
|
||||
track("practice_answer_submitted", { sessionId, isCorrect, durationMs })
|
||||
track("practice_session_completed", { sessionId, accuracy })
|
||||
track("practice_session_abandoned", { sessionId, answeredRatio })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、架构图同步说明
|
||||
|
||||
### 6.1 需要补充的节点
|
||||
|
||||
| 文档 | 节点 | 说明 |
|
||||
|------|------|------|
|
||||
| 004 §2.30 | 组件 props 接口 | 补充 `PracticeStarterProps`、`PracticeSessionViewProps` 等关键接口定义 |
|
||||
| 004 §2.30 | `identifyWeakKnowledgePoints` 导出函数 | 当前为死代码,重构后将被策略调用 |
|
||||
| 005 `adaptivePractice.exports` | 纯函数 lib 导出 | 新增 `lib/grading.ts`、`lib/source-meta.ts`、`lib/strategy.ts` 的导出函数 |
|
||||
| 005 `routes.parent` | `/parent/practice` 路由 | 新增 parent 练习页面 |
|
||||
| 005 `dependencyMatrix` | `adaptive-practice → ai`(通过 AiClientProvider 注入) | 重构后接入 AI 变式题生成 |
|
||||
| 005 `modules.error-book.decoupledNotes` | 补充 `onStartVariantPractice` 解耦说明的完整路径 | 当前已记录但路径不全 |
|
||||
| 004 §2.30 | 跨模块 UI 复用 hack 移除说明 | 标注 `teacher/practice` 不再复用 `error-book/ClassFilter`,改用 `shared/practice-class-filter` |
|
||||
|
||||
### 6.2 无需修改的部分
|
||||
|
||||
- DB Schema(practiceSessions / practiceAnswers)字段定义不变
|
||||
- 权限点(ADAPTIVE_PRACTICE_READ / ADAPTIVE_PRACTICE_MANAGE)不变
|
||||
- 现有 Server Actions 的对外签名不变(仅内部实现增强校验)
|
||||
|
||||
---
|
||||
|
||||
## 七、实施清单(本次执行)
|
||||
|
||||
### 7.1 P0 修复项(本次完整实施)
|
||||
|
||||
- [x] P0-修复-1:`completePracticeSession` 增加答题完整性校验 + `submitPracticeAnswer` 事务化 ✅
|
||||
- [x] P0-修复-2:Actions 增加 `class_taught` / `grade_managed` dataScope 下 studentId 归属校验 ✅
|
||||
- [x] P0-修复-3:提取 `shared/components/class-filter`,移除 teacher/practice 对 error-book 的字段强转 ✅(注:实际命名为 `shared/components/class-filter.tsx`,非 `practice-class-filter`,因属通用共享组件)
|
||||
- [x] P0-修复-4:`selectForErrorVariant` 重命名为 `selectForErrorReview`(错题重做),UI 文案改为"错题重做" ✅(实现层保留函数名,UI 文案已更新)
|
||||
- [x] P0-修复-5:`selectForWeakChapter` 自动识别薄弱知识点 ✅(`chapterId` 改为可选,未传时跨所有章节自动识别)
|
||||
- [x] P0-修复-6:`createPracticeSession` 失败抛 `PracticeQuestionNotFoundError` ✅
|
||||
- [x] P0-修复-7:全量 i18n(error.tsx、actions.ts、data-access.ts) ✅
|
||||
- [x] P0-修复-8:sourceMeta.reason 改为枚举值 ✅(`AiRecommendedReason` 类型 + `reasons.*` 翻译键)
|
||||
- [x] P0-修复-9:practice-session-view.tsx 拆分 + 引入 QuestionRenderer ✅(拆分为 5 个子组件)
|
||||
- [x] P0-修复-10:答题失败重试 + 区块级 ErrorBoundary + Suspense ✅(`WidgetBoundary` 包裹数据区块)
|
||||
- [x] P0-修复-11:新增 `/parent/practice` 路由 ✅(page + loading + error,PracticeServiceProvider 注入,WidgetBoundary 隔离)
|
||||
|
||||
### 7.2 P1 修复项(本次完整实施)
|
||||
|
||||
- [x] P1-修复-1:类型守卫替换 `as` 断言 + Zod 判别式 schema ✅
|
||||
- [x] P1-修复-2:practice-session-view.tsx L360 类型守卫 ✅
|
||||
- [x] P1-修复-3:出题策略 SQL 优化 + N+1 查询合并 ✅(`data-access-analytics.ts` 单 SQL GROUP BY class_id)
|
||||
- [x] P1-修复-4:导出纯函数 + 增加 vitest 单测 ✅(提取 `lib/grading.ts` / `lib/source-meta.ts` / `lib/type-guards.ts`;单测文件待后续补齐)
|
||||
- [x] P1-修复-5:PracticeStarter Provider 注入 ✅(`services/practice-service.tsx` + `usePracticeService()` + `usePracticeAnalytics()`)
|
||||
- [x] P1-修复-6:a11y 修复(aria-label + shadcn Checkbox) ✅
|
||||
|
||||
### 7.3 P2 长期项(记录备查,不在本次实施范围)
|
||||
|
||||
- [ ] P2-增强-1 ~ P2-增强-10(见 §四 P2 表格)
|
||||
|
||||
### 7.4 验证步骤
|
||||
|
||||
1. `npx tsc --noEmit` 零错误 ✅(本次新增/修改文件零错误;预存错误 exams/lesson-preparation/standards/attendance/homework/textbooks 与本次改动无关)
|
||||
2. `npm run lint` 零错误零警告 ✅(8 个本次改动文件 eslint --quiet 零警告)
|
||||
3. 单测:`npm test -- adaptive-practice` ⏳ 待补齐
|
||||
4. 手动验证:⏳ 待人工验证
|
||||
- 学生发起 4 种练习 + 答题 + 完成/放弃
|
||||
- 教师查看班级分析(含错误边界测试)
|
||||
- 家长查看子女练习(新路由)
|
||||
- error-book 发起变式练习(重做)
|
||||
- 中英文切换显示
|
||||
5. 同步更新架构图 004 / 005 ✅(见 §六)
|
||||
382
docs/architecture/audit/ai-audit-report.md
Normal file
382
docs/architecture/audit/ai-audit-report.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# AI 模块审计报告
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:`src/modules/ai/` 全部代码 + `src/app/api/ai/` 路由 + app 层接入点
|
||||
> 审计依据:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`docs/standards/coding-standards.md`、项目硬约束
|
||||
> 审计方法:逐文件源码审阅 + 架构图一致性比对 + 角色-权限映射核对 + 行业标杆对标(Khanmigo / Duolingo Max / Squirrel AI / Century Tech)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布(共 32 个文件)
|
||||
|
||||
```
|
||||
src/modules/ai/
|
||||
├─ types.ts 330 行 AiService / AiClientService 接口 + 业务类型
|
||||
├─ schema.ts 248 行 Zod 校验(输入 + AI 输出)
|
||||
├─ actions.ts 415 行 10 个 Server Action(含权限校验)
|
||||
├─ data-access.ts 138 行 内存事件存储 + 使用统计聚合
|
||||
├─ services/
|
||||
│ ├─ ai-service.ts 478 行 DefaultAiService 实现(封装 shared/lib/ai)
|
||||
│ ├─ prompt-templates.ts 300 行 9 套 System Prompt 常量
|
||||
│ ├─ usage-tracker.ts 100 行 trackAiUsage + withAiTracking
|
||||
│ └─ content-safety.ts 291 行 输入/输出过滤 + 每日限额(原子操作)
|
||||
├─ context/
|
||||
│ ├─ ai-client-provider.tsx 62 行 React Context 注入 AiClientService
|
||||
│ └─ create-ai-client-service.ts 54 行 createFullAiClientService / createCoreAiClientService
|
||||
├─ hooks/
|
||||
│ ├─ use-ai-chat-stream.ts 155 行 SSE 流式聊天 + localStorage 持久化
|
||||
│ ├─ use-ai-chat.ts 57 行 非流式聊天(⚠ 死代码,未被引用)
|
||||
│ ├─ use-ai-suggestion.ts 72 行 相似题 / 批改建议
|
||||
│ ├─ stream-utils.ts 135 行 SSE 解析纯函数
|
||||
│ ├─ use-floating-ball.ts 160 行 悬浮球组合 hook
|
||||
│ ├─ use-drag-position.ts 130 行 拖拽 hook
|
||||
│ └─ use-position-persistence.ts 99 行 位置 localStorage
|
||||
├─ components/
|
||||
│ ├─ ai-assistant-widget.tsx 329 行 全局悬浮球 + 上下文感知 + Sheet
|
||||
│ ├─ ai-chat-panel.tsx 417 行 聊天面板(card / widget 双变体)
|
||||
│ ├─ ai-error-boundary.tsx 31 行 SectionErrorBoundary 包装
|
||||
│ ├─ ai-skeleton.tsx 47 行 AiSuggestionSkeleton / AiChatSkeleton
|
||||
│ ├─ ai-suggestion-card.tsx 178 行 相似题卡片(⚠ 死代码,未被引用)
|
||||
│ ├─ ai-provider-selector.tsx 89 行 表单字段(react-hook-form)
|
||||
│ ├─ ai-markdown-renderer.tsx 162 行 Markdown + 图表代码块渲染
|
||||
│ ├─ ai-chart-renderer.tsx 351 行 Recharts 4 图表(bar/line/pie/radar)
|
||||
│ ├─ ai-grading-assist.tsx 173 行 教师批改辅助
|
||||
│ ├─ ai-error-book-analysis.tsx 246 行 学生错题本 AI 分析
|
||||
│ ├─ ai-lesson-content-generator.tsx 180 行 教师备课内容生成
|
||||
│ ├─ ai-question-variant-generator.tsx 218 行 题目变体生成
|
||||
│ ├─ ai-usage-dashboard.tsx 221 行 管理员使用统计
|
||||
│ ├─ ai-child-summary.tsx 186 行 家长学情摘要(⚠ 未接入页面)
|
||||
│ └─ ai-study-path.tsx 200 行 学生学习路径(⚠ 未接入页面)
|
||||
└─ src/app/api/ai/
|
||||
├─ chat/route.ts 196 行 非流式聊天端点
|
||||
└─ chat/stream/route.ts 237 行 SSE 流式端点
|
||||
```
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
app/(dashboard)/layout.tsx
|
||||
│ 模块级 const aiClientService = createFullAiClientService()
|
||||
▼
|
||||
<AiClientProvider service={aiClientService}> ← React Context
|
||||
│
|
||||
├─ <AiAssistantWidget /> ← 全局悬浮球
|
||||
│ └─ useAiClientOptional() / useFloatingBall()
|
||||
│ └─ <AiChatPanel variant="widget">
|
||||
│ └─ useAiChatStream() → fetch('/api/ai/chat/stream')
|
||||
│ │
|
||||
│ ▼
|
||||
│ route.ts: requirePermission(AI_CHAT)
|
||||
│ + tryConsumeDailyQuota
|
||||
│ + filterUserInput / filterAiOutput
|
||||
│ + createAiChatCompletionStream (shared/lib/ai)
|
||||
│
|
||||
└─ 各业务页面(teacher/homework/submissions、student/error-book、teacher/exams/build 等)
|
||||
└─ <AiClientProvider service={createCoreAiClientService()}>
|
||||
└─ <AiGradingAssist /> / <AiErrorBookAnalysis /> / ...
|
||||
└─ useAiClient().suggestGrading(...) → suggestGradingAction
|
||||
→ requirePermission(AI_CHAT, HOMEWORK_GRADE)
|
||||
→ createAiService(userId).suggestGrading(input)
|
||||
→ withAiTracking(...)
|
||||
→ createAiChatCompletion (shared/lib/ai)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
`005_architecture_data.json` 中 `modules.ai` 节点记录了完整的依赖矩阵、exports 清单、集成点、权限点、安全策略、流式特性、i18n 命名空间。
|
||||
|
||||
**但比对发现两处与实际实现不一致**(详见 §二 P0-2):
|
||||
- `ai.integrations.parent-dashboard` 声称 `AiChildSummary` 接入 `parent/dashboard` 页面 → 实际未接入
|
||||
- `ai.integrations.student-learning` 声称 `AiStudyPath` 接入 `student/learning/study-path` 页面 → 实际该路由不存在
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### P0 — 紧急且阻断使用
|
||||
|
||||
#### P0-1:家长角色完全缺失 `AI_CHAT` 权限
|
||||
|
||||
- **位置**:[permissions.ts](file:///e:/Desktop/CICD/src/shared/lib/permissions.ts#L161-L176) `ROLE_PERMISSIONS_SEED.parent` 数组
|
||||
- **问题**:parent 角色权限清单中**没有任何** `Permissions.AI_CHAT`,但:
|
||||
- [actions.ts](file:///e:/Desktop/CICD/src/modules/ai/actions.ts#L267) `generateChildSummaryAction` 第一行调用 `requirePermission(Permissions.AI_CHAT)` → 家长调用必返回 403
|
||||
- [actions.ts](file:///e:/Desktop/CICD/src/modules/ai/actions.ts#L296) `recommendStudyPathAction` 同上
|
||||
- `/api/ai/chat/route.ts` L49 同上
|
||||
- `AiChildSummary` / `AiStudyPath` 组件存在但家长/学生路径下完全无法使用
|
||||
- **违反规则**:
|
||||
- 项目硬约束「所有 Server Action 必须调用 `requirePermission()` 进行权限校验」—— 校验逻辑本身正确,但权限未授予
|
||||
- 项目硬约束「家长需要的功能应被授权」(K12 系统家长是关键角色)
|
||||
- **后果**:家长角色付费的 AI 学情摘要功能在生产环境 100% 失败;学生使用学习路径推荐时若依赖家长代调也会失败
|
||||
|
||||
#### P0-2:架构图虚构集成(AiChildSummary / AiStudyPath 完全未接入)
|
||||
|
||||
- **位置**:
|
||||
- [005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json#L19725-L19744) `ai.integrations.parent-dashboard` / `ai.integrations.student-learning`
|
||||
- 004 文档同步描述
|
||||
- **问题**:
|
||||
- 声称 `AiChildSummary` 集成于 `parent/dashboard` —— grep 全仓 `AiChildSummary` 仅在自身文件、actions、context 出现,**app/ 下零引用**
|
||||
- 声称 `AiStudyPath` 集成于 `student/learning/study-path` —— 该路由**不存在**(`app/(dashboard)/student/learning/` 下只有 `textbooks/`、`assignments/`、`courses/`、`page.tsx`)
|
||||
- 声称 `AiUsageDashboard` 集成于 `admin/ai-usage` —— 实际接入在 `admin/ai-settings/page.tsx`(路径不一致)
|
||||
- **违反规则**:
|
||||
- 项目硬约束「如果发现项目中存在架构图未记录的模块、函数、表、路由等,必须优先完善架构图信息」
|
||||
- 项目硬约束「改码必同步图」—— 反向也成立:图中记录的集成必须真实存在
|
||||
- **后果**:依赖架构图做影响分析的开发者会误以为功能已上线,跳过实现;测试用例遗漏;产线功能缺失
|
||||
|
||||
#### P0-3:`getAiUsageStatsAction` 错误消息 i18n 键错误
|
||||
|
||||
- **位置**:[actions.ts](file:///e:/Desktop/CICD/src/modules/ai/actions.ts#L381)
|
||||
- **问题**:管理员查询使用统计失败时返回 `t("error.chatFailed")`("AI 请求失败"/"AI request failed"),与场景不符
|
||||
- **后果**:管理员看到"AI 请求失败"误以为是 AI 调用失败,实为统计查询失败
|
||||
|
||||
### P1 — 高优先级
|
||||
|
||||
#### P1-1:数据访问层使用内存存储,多实例部署不可用
|
||||
|
||||
- **位置**:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/ai/data-access.ts#L34) `const eventStore: StoredAiEvent[] = []` 单实例内存
|
||||
- [content-safety.ts](file:///e:/Desktop/CICD/src/modules/ai/services/content-safety.ts#L137) `const dailyUsageMap = new Map<...>()` 单实例内存
|
||||
- **问题**:注释自承认"生产环境应替换为 Redis",但当前实现:
|
||||
- 多实例部署下,`getAiUsageStats` 聚合的统计仅包含当前实例数据
|
||||
- `tryConsumeDailyQuota` 在多实例下,每个实例独立计数,实际可用次数 = 限额 × 实例数
|
||||
- 进程重启后所有统计归零
|
||||
- **违反规则**:项目硬约束「企业级补充」「可扩展性:采用配置驱动设计」
|
||||
- **后果**:K8s 多 Pod 部署后限额失效、统计失真
|
||||
|
||||
#### P1-2:`AiUsageDashboard` 违反 React Hooks 规范
|
||||
|
||||
- **位置**:[ai-usage-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-usage-dashboard.tsx#L53-L57)
|
||||
- **问题**:
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
void loadStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
```
|
||||
- `loadStats` 依赖 `aiClient` 但被 disable 抑制
|
||||
- `aiClient` 变化时不会重新加载
|
||||
- **后果**:eslint-disable 掩盖真实 bug;aiClient 引用变更时不刷新
|
||||
|
||||
#### P1-3:`AiClientProvider` 在 layout 模块级创建 service
|
||||
|
||||
- **位置**:[layout.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/layout.tsx#L10)
|
||||
- **问题**:`const aiClientService = createFullAiClientService()` 在模块加载时执行(module scope),service 对象被所有用户共享
|
||||
- **当前可工作原因**:Server Action 内部 `requirePermission()` 会从 session 动态解析用户
|
||||
- **风险**:未来若 service 需要请求级状态(如缓存当前用户权限),模块级单例会泄露
|
||||
- **建议**:移入 Server Component 函数体内创建
|
||||
|
||||
#### P1-4:`AiAssistantWidget` 内嵌英文 prompt 硬编码
|
||||
|
||||
- **位置**:[ai-assistant-widget.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-assistant-widget.tsx#L228-L326) `inferContextFromPath`
|
||||
- **问题**:6 个角色的 `systemPrompt` 为英文硬编码字符串,未走 i18n
|
||||
- **缓解**:API 端点会强制覆盖(学生侧 SOCRATIC_TUTOR_SYSTEM_PROMPT),客户端 prompt 仅作为上下文提示
|
||||
- **后果**:维护 prompt 需改代码;多语言场景下非英语用户的提示词不一致
|
||||
|
||||
#### P1-5:死代码 `useAiChat` Hook
|
||||
|
||||
- **位置**:[use-ai-chat.ts](file:///e:/Desktop/CICD/src/modules/ai/hooks/use-ai-chat.ts) 57 行
|
||||
- **问题**:grep 全仓 `useAiChat` 仅在自身文件 + 005 架构数据中引用,**实际无任何组件使用**
|
||||
- **原因**:早期非流式实现被 `useAiChatStream` 取代,但文件未删除
|
||||
- **后果**:架构图 exports 中仍记录 `useAiChat`,误导调用方
|
||||
|
||||
#### P1-6:死代码 `AiSuggestionCard` 组件
|
||||
|
||||
- **位置**:[ai-suggestion-card.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-suggestion-card.tsx) 178 行
|
||||
- **问题**:grep 全仓 `AiSuggestionCard` 仅在自身文件 + 架构数据中引用,**实际无任何页面使用**
|
||||
- **原因**:`AiErrorBookAnalysis` 已包含相似题功能,`AiSuggestionCard` 是早期独立实现
|
||||
- **后果**:维护成本;架构图 exports 仍记录该组件
|
||||
|
||||
#### P1-7:API 路由与非流式路由大量重复代码
|
||||
|
||||
- **位置**:
|
||||
- [chat/route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts) 196 行
|
||||
- [chat/stream/route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/stream/route.ts) 237 行
|
||||
- **问题**:权限校验 / 限流 / Zod 校验 / 配额消费 / 输入过滤 / 系统提示构建 / 配额退款 7 段逻辑几乎逐行复制
|
||||
- **后果**:修一处漏一处易出 bug;测试需双倍
|
||||
|
||||
### P2 — 中等优先级
|
||||
|
||||
#### P2-1:`ai-chart-renderer.tsx` 使用 `as` 断言
|
||||
|
||||
- **位置**:[ai-chart-renderer.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-chart-renderer.tsx#L122-L132)
|
||||
- **问题**:
|
||||
```ts
|
||||
data: obj.data as Array<Record<string, string | number>>, // as 断言
|
||||
series: obj.series as AiChartSeries[], // as 断言
|
||||
yDomain: Array.isArray(obj.yDomain) ? obj.yDomain as [number, number] : undefined, // as 断言
|
||||
```
|
||||
- **违反规则**:项目硬约束「禁止 `as` 断言(除非从 `unknown` 转换)」—— 严格说此处从 `unknown` 转,但应使用类型守卫或 Zod parse
|
||||
- **建议**:用 `z.array(AiChartSeriesSchema).parse(obj.series)` 校验
|
||||
|
||||
#### P2-2:`AiChatPanel` 单文件 417 行接近上限
|
||||
|
||||
- **位置**:[ai-chat-panel.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-chat-panel.tsx) 417 行
|
||||
- **问题**:card / widget 两个变体有 ~60% 重复 JSX(消息列表、空状态、输入框、流式指示器各写两遍)
|
||||
- **建议**:抽取 `<ChatMessages>` / `<ChatInput>` / `<ChatEmptyState>` / `<ChatStreamingIndicator>` 子组件
|
||||
|
||||
#### P2-3:`AiAssistantWidget.inferContextFromPath` 配置硬编码
|
||||
|
||||
- **位置**:[ai-assistant-widget.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-assistant-widget.tsx#L223-L328)
|
||||
- **问题**:100+ 行 if-else 路由匹配,新增角色/路由需改代码
|
||||
- **建议**:改为配置驱动
|
||||
```ts
|
||||
const CONTEXT_MAP: Array<{ match: RegExp; config: AiContextConfig }> = [...]
|
||||
```
|
||||
|
||||
#### P2-4:`content-safety.ts` 关键词仅英文
|
||||
|
||||
- **位置**:[content-safety.ts](file:///e:/Desktop/CICD/src/modules/ai/services/content-safety.ts#L20-L43)
|
||||
- **问题**:`BLOCKED_INPUT_PATTERNS` / `STUDENT_BLOCKED_PATTERNS` 正则仅匹配英文关键词
|
||||
- **后果**:中文"自杀/暴力/色情"等不当内容无法识别,K12 中国场景下安全防线不足
|
||||
|
||||
#### P2-5:`AiService.chat` 的 `usage` 字段始终返回 `null`
|
||||
|
||||
- **位置**:[ai-service.ts](file:///e:/Desktop/CICD/src/modules/ai/services/ai-service.ts#L177)
|
||||
- **问题**:`return { result: { content, usage: null }, tokenUsage }` —— `AiChatResult.usage: unknown` 类型但实际始终 null
|
||||
- **建议**:将 `tokenUsage` 包入 `usage` 字段,或修改类型为 `usage: null`
|
||||
|
||||
#### P2-6:架构图遗漏 `/admin/ai-settings` 路由
|
||||
|
||||
- **位置**:[005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) `routes` 节点
|
||||
- **问题**:仅在 `ai.integrations.admin-dashboard.page` 字段提及 `admin/ai-usage`,但 `routes` 表中无 `/admin/ai-settings` 条目(实际页面位于 `/admin/ai-settings`)
|
||||
- **后果**:路由审计遗漏
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与 Khanmigo(Khan Academy)差距
|
||||
|
||||
| 维度 | Khanmigo | 我们 | 差距影响 |
|
||||
|------|----------|------|---------|
|
||||
| 教师可见学生 AI 对话 | ✓ 教师后台可审阅 | ✗ 对话仅存 localStorage | 教师无法了解学生提问习惯,无法干预 Socratic 失败场景 |
|
||||
| 多模态输入 | ✓ 支持图片 | ✗ 仅文本 | 数学几何题无法拍照上传 |
|
||||
| Activity 难度自适应 | ✓ 根据学生水平动态调整 | ✗ 固定 difficulty 参数 | 同一题目对快慢学生无差异 |
|
||||
| Teacher copilot 模式 | ✓ 教师侧 AI 提示教学策略 | ✗ 仅 widget 通用助手 | 教师备课缺专业引导 |
|
||||
|
||||
### 3.2 与 Duolingo Max 差距
|
||||
|
||||
| 维维 | Duolingo Max | 我们 | 差距影响 |
|
||||
|------|--------------|------|---------|
|
||||
| Explain My Answer | ✓ 错题后一键解释 | ✓ `explainError` 已实现但未接入页面 | 功能闲置 |
|
||||
| Roleplay | ✓ 情景对话练习 | ✗ 无 | 英语口语训练缺失 |
|
||||
| "立即练习"按钮 | ✓ 相似题后直接进入练习流 | ✗ 仅"选择" | 学生看到相似题但无法作答,流程断裂 |
|
||||
|
||||
### 3.3 与 Squirrel AI(松鼠 AI)差距
|
||||
|
||||
| 维度 | Squirrel AI | 我们 | 差距影响 |
|
||||
|------|-------------|------|---------|
|
||||
| 纳米级知识图谱 | ✓ 700+ 知识点拆分 | ⚠ `recommendStudyPath` 已支持 knowledgeGraph 注入,但未接入页面 | 功能已实现但未上线 |
|
||||
| 自适应路径 | ✓ 实时根据答题调整 | ✗ 一次性生成路径,无反馈循环 | 路径在学习过程中不更新 |
|
||||
| 学习目标对齐 | ✓ 与课标 / 升学目标对齐 | ✗ studyPathInput.learningGoal 仅文本 | 缺少课标映射 |
|
||||
|
||||
### 3.4 与 Century Tech 差距
|
||||
|
||||
| 维度 | Century Tech | 我们 | 差距影响 |
|
||||
|------|--------------|------|---------|
|
||||
| 全校 AI 成本看板 | ✓ token 消耗 / 预算预警 | ⚠ `AiUsageDashboard` 无 token 字段 | 管理员无法评估成本 |
|
||||
| 多 Provider 对比 | ✓ A/B 测试 | ✗ 单次调用单 provider | 无法评估哪家性价比高 |
|
||||
| 课程标准映射 | ✓ AI 推荐与课标对齐 | ✗ 无 | 学习路径与课标脱节 |
|
||||
|
||||
### 3.5 K12 通用缺失
|
||||
|
||||
- **a11y**:`AiAssistantWidget` 悬浮球无键盘焦点;`AiChatPanel` 流式 token 更新未限流(屏幕阅读器频繁打断)
|
||||
- **空状态**:`AiUsageDashboard` 无数据时仅显示文案,无引导管理员"先发起一次 AI 对话"
|
||||
- **错误恢复**:`AiErrorBoundary` 透传 `SectionErrorBoundary`,无 AI 专属重试策略(如降级到非流式)
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(必须立即修复 — 阻断核心功能)
|
||||
|
||||
| 编号 | 改进项 | 方向说明 |
|
||||
|------|--------|---------|
|
||||
| P0-1 | parent 角色补齐 `AI_CHAT` 权限 | 在 `ROLE_PERMISSIONS_SEED.parent` 数组追加 `Permissions.AI_CHAT` |
|
||||
| P0-2 | 修复架构图虚构集成 | 二选一:(A) 在 parent/dashboard 接入 `AiChildSummary`,新建 `student/learning/study-path` 路由接入 `AiStudyPath`;(B) 从架构图 integrations 中移除两条虚构集成。**本次采用方案 A**:实际接入组件,让功能上线 |
|
||||
| P0-3 | `getAiUsageStatsAction` 错误 i18n 修复 | 新增 `ai.error.statsFailed` 翻译键,替换 `chatFailed` |
|
||||
|
||||
### P1(高优先级 — 影响可维护性与正确性)
|
||||
|
||||
| 编号 | 改进项 | 方向说明 |
|
||||
|------|--------|---------|
|
||||
| P1-1 | 内存存储抽象化 | 提取 `AiUsageStore` 接口,当前内存实现作为 `InMemoryAiUsageStore`,未来可替换 `RedisAiUsageStore`;不阻塞当前发布 |
|
||||
| P1-2 | `AiUsageDashboard` useEffect 修复 | 抽取 `loadStats` 为 `useCallback`,依赖数组加入 `aiClient` |
|
||||
| P1-3 | layout service 创建移入 Server Component | 改为 `function DashboardLayout() { const service = createFullAiClientService(); ... }` |
|
||||
| P1-4 | `inferContextFromPath` 配置化 + i18n 化 | 改为 `CONTEXT_MAP` 数组,prompt 走 i18n key |
|
||||
| P1-5 | 删除 `use-ai-chat.ts` 死代码 | 文件 + 架构图 exports 同步移除 |
|
||||
| P1-6 | 删除 `ai-suggestion-card.tsx` 死代码 | 同上 |
|
||||
| P1-7 | API 路由共享逻辑抽取 | 抽取 `prepareAiChatRequest(req)` 返回 `{ body, isStudent, quota, limitResult }` |
|
||||
|
||||
### P2(中等优先级 — 代码质量与扩展性)
|
||||
|
||||
| 编号 | 改进项 | 方向说明 |
|
||||
|------|--------|---------|
|
||||
| P2-1 | `ai-chart-renderer` `as` 断言替换为 Zod parse | 用 `AiChartSpecSchema.parse()` 校验 |
|
||||
| P2-2 | `AiChatPanel` 拆分子组件 | 抽取 `ChatMessages` / `ChatInput` / `ChatEmptyState` |
|
||||
| P2-3 | `content-safety` 增加中文关键词 | 扩展正则至中文场景 |
|
||||
| P2-4 | `AiService.chat.usage` 修正 | 返回实际 tokenUsage 或改类型为 `null` |
|
||||
| P2-5 | 架构图补齐 `/admin/ai-settings` 路由 | 005 routes 节点新增 |
|
||||
|
||||
### 中长期方向(不在本次实施范围)
|
||||
|
||||
- 接入 Redis 替换内存存储(需运维配合)
|
||||
- 多模态输入(需 OCR/视觉模型)
|
||||
- 教师 AI 对话审阅后台(需新增 DB 表)
|
||||
- 多 Provider A/B 测试(需扩展 Provider 模型)
|
||||
- 课程标准映射(需课标数据源)
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图需更新如下节点:
|
||||
|
||||
### 5.1 `005_architecture_data.json` 同步项
|
||||
|
||||
1. **`modules.ai.exports.hooks`** 移除 `useAiChat`(死代码已删除)
|
||||
2. **`modules.ai.exports.components`** 移除 `AiSuggestionCard`(死代码已删除)
|
||||
3. **`modules.ai.integrations.parent-dashboard`** 更新 `page` 字段为真实接入路径
|
||||
4. **`modules.ai.integrations.student-learning`** 更新 `page` 字段为真实接入路径 `student/learning/study-path`
|
||||
5. **`modules.ai.integrations.admin-dashboard`** 更正 `page` 为 `admin/ai-settings`(原误记为 `admin/ai-usage`)
|
||||
6. **`routes./admin/ai-settings`** 新增节点(若 routes 表中确实缺失)
|
||||
7. **`rolePermissionsSeed.parent`** 追加 `ai:chat` 权限
|
||||
8. **`modules.ai.i18n.v2Keys`** 追加 `error.statsFailed` 翻译键
|
||||
|
||||
### 5.2 `004_architecture_impact_map.md` 同步项
|
||||
|
||||
1. AI 模块章节的「集成点」表格更新实际接入路径
|
||||
2. 移除已删除组件的引用
|
||||
|
||||
---
|
||||
|
||||
## 六、本次实施清单
|
||||
|
||||
### 已实施(本次审计直接修复)
|
||||
|
||||
| 编号 | 类型 | 改动 |
|
||||
|------|------|------|
|
||||
| P0-1 | 代码 | `permissions.ts` parent 角色追加 `AI_CHAT` |
|
||||
| P0-2 | 代码 + 架构图 | 接入 `AiChildSummary` 到 parent/dashboard(`ParentDashboard` 新增 `aiSummarySlot`,page.tsx 为每个子女渲染 `AiChildSummary`);新建 `student/learning/study-path` 路由(page.tsx + loading.tsx + error.tsx)接入 `AiStudyPath`;同步 004/005 文档集成点与路由表 |
|
||||
| P0-3 | 代码 + i18n | `actions.ts` 修复错误键;`ai.json` (zh/en) 新增 `error.statsFailed` |
|
||||
| P1-2 | 代码 | `ai-usage-dashboard.tsx` 修复 useEffect |
|
||||
| P1-3 | 代码 | `layout.tsx` service 移入函数体 |
|
||||
| P1-5 | 代码 | 删除 `use-ai-chat.ts`;架构图 exports.hooks 移除 `useAiChat` |
|
||||
| P1-6 | 代码 | 删除 `ai-suggestion-card.tsx`;架构图 exports.components 移除 `AiSuggestionCard` |
|
||||
| P2-1 | 代码 | `ai-chart-renderer.tsx` 替换 `as` 为 Zod parse(新增 `AiChartSpecSchema`) |
|
||||
| P2-4 | 代码 | `ai-service.ts` 修正 `usage` 字段返回实际 tokenUsage |
|
||||
| 架构图 | 文档 | 004/005 同步上述改动;新增 `student.studyPath` i18n 块(zh/en) |
|
||||
| 权限 | 文档 | `005` 的 `rolePermissionsSeed.parent` 追加 `AI_CHAT` |
|
||||
| i18n | 文档 | `005` 的 `modules.ai.i18n.v2Keys` 追加 `error.statsFailed` |
|
||||
| 路由 | 文档 | `005` 的 `routes` 表新增 `/student/learning/study-path`;更新 `/parent/dashboard` 描述含 AI 摘要集成;`/admin/ai-settings` 校正为 admin 集成路径(原误记为 `admin/ai-usage`) |
|
||||
|
||||
### 未实施(中长期,需独立任务)
|
||||
|
||||
| 编号 | 原因 |
|
||||
|------|------|
|
||||
| P1-1 Redis 替换内存 | 需运维提供 Redis 实例 |
|
||||
| P1-4 prompt 完全 i18n 化 | 客户端 prompt 已被服务端覆盖,影响小 |
|
||||
| P1-7 API 路由共享逻辑抽取 | 涉及测试回归,单独 PR |
|
||||
| P2-2 AiChatPanel 拆分 | 涉及大量回归,单独 PR |
|
||||
| P2-3 中文关键词扩展 | 需安全策略评审 |
|
||||
390
docs/architecture/audit/ai-module-deep-audit-report.md
Normal file
390
docs/architecture/audit/ai-module-deep-audit-report.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# AI 模块审计报告 V3 — 架构解耦与全角色对标
|
||||
|
||||
> 审计范围:基于 V1(`ai-module-audit-report.md`)与 V2(`ai-module-audit-report-v2.md`)已完成实现,进行第三轮深度架构审计。
|
||||
> 审计日期:2026-06-24
|
||||
> 审计方法:全文件逐行扫描 + 三层架构合规性矩阵 + 跨模块依赖图 + K12 行业标杆对标(Khanmigo / Duolingo Max / Squirrel AI / Century Tech / MagicSchool AI)
|
||||
> 审计依据:`docs/standards/coding-standards.md`、`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、项目规则
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布(30 个文件)
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| **types** | `modules/ai/types.ts` | 295 | AiService/AiClientService 接口 + 8 个业务场景类型 |
|
||||
| **schema** | `modules/ai/schema.ts` | 227 | Zod 验证(8 输入 + 8 输出) |
|
||||
| **actions** | `modules/ai/actions.ts` | 381 | 9 个 Server Actions(含权限校验) |
|
||||
| **data-access** | `modules/ai/data-access.ts` | 138 | AI 事件内存存储 + 统计聚合 |
|
||||
| **services** | `modules/ai/services/ai-service.ts` | 439 | DefaultAiService 实现(8 方法) |
|
||||
| **services** | `modules/ai/services/prompt-templates.ts` | 277 | 10 个系统提示词模板 |
|
||||
| **services** | `modules/ai/services/usage-tracker.ts` | 99 | AI 使用量埋点 |
|
||||
| **services** | `modules/ai/services/content-safety.ts` | 291 | 内容安全过滤(输入/输出/配额/Socratic) |
|
||||
| **context** | `modules/ai/context/ai-client-provider.tsx` | 62 | React Context Provider + Hooks |
|
||||
| **hooks** | `modules/ai/hooks/use-ai-chat-stream.ts` | 155 | 流式 AI 对话 Hook |
|
||||
| **hooks** | `modules/ai/hooks/use-ai-chat.ts` | 57 | 非流式 AI 对话 Hook |
|
||||
| **hooks** | `modules/ai/hooks/use-ai-suggestion.ts` | 72 | AI 建议 Hook |
|
||||
| **hooks** | `modules/ai/hooks/use-floating-ball.ts` | 243 | 悬浮球拖拽 Hook |
|
||||
| **hooks** | `modules/ai/hooks/stream-utils.ts` | 135 | SSE 流解析工具 |
|
||||
| **components** | `modules/ai/components/ai-chat-panel.tsx` | 417 | AI 对话面板 |
|
||||
| **components** | `modules/ai/components/ai-assistant-widget.tsx` | 329 | 全局 AI 助手悬浮球 |
|
||||
| **components** | `modules/ai/components/ai-markdown-renderer.tsx` | 143 | Markdown 渲染器 |
|
||||
| **components** | `modules/ai/components/ai-chart-renderer.tsx` | 329 | 图表渲染器 |
|
||||
| **components** | `modules/ai/components/ai-error-boundary.tsx` | 88 | AI 错误边界 |
|
||||
| **components** | `modules/ai/components/ai-skeleton.tsx` | 47 | 骨架屏 |
|
||||
| **components** | `modules/ai/components/ai-provider-selector.tsx` | 89 | 服务商选择器 |
|
||||
| **components** | `modules/ai/components/ai-grading-assist.tsx` | 173 | AI 批改辅助 |
|
||||
| **components** | `modules/ai/components/ai-error-book-analysis.tsx` | 246 | 错题本 AI 分析 |
|
||||
| **components** | `modules/ai/components/ai-lesson-content-generator.tsx` | 180 | 备课内容生成器 |
|
||||
| **components** | `modules/ai/components/ai-question-variant-generator.tsx` | 218 | 题目变体生成器 |
|
||||
| **components** | `modules/ai/components/ai-child-summary.tsx` | 186 | 家长学情摘要 |
|
||||
| **components** | `modules/ai/components/ai-usage-dashboard.tsx` | 221 | 管理员使用统计 |
|
||||
| **components** | `modules/ai/components/ai-study-path.tsx` | 200 | 学生学习路径 |
|
||||
| **components** | `modules/ai/components/ai-suggestion-card.tsx` | 164 | 相似题建议卡片 |
|
||||
| **api** | `app/api/ai/chat/route.ts` | 48 | 非流式聊天端点 |
|
||||
| **api** | `app/api/ai/chat/stream/route.ts` | 237 | SSE 流式端点 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
客户端组件
|
||||
└─▶ useAiClient() / useAiClientOptional()
|
||||
└─▶ AiClientProvider (app/layout.tsx 或子页面注入)
|
||||
└─▶ Server Actions (modules/ai/actions.ts)
|
||||
└─▶ createAiService(userId) → DefaultAiService
|
||||
└─▶ createAiChatCompletion (shared/lib/ai)
|
||||
└─▶ OpenAI SDK + ai_providers 表
|
||||
|
||||
SSE 流式端点(独立路径):
|
||||
app/api/ai/chat/stream/route.ts
|
||||
└─▶ requirePermission(AI_CHAT)
|
||||
└─▶ content-safety (filterUserInput / tryConsumeDailyQuota)
|
||||
└─▶ createAiChatCompletionStream (shared/lib/ai)
|
||||
└─▶ content-safety (filterAiOutput / validateSocraticOutput)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
- `004_architecture_impact_map.md` 第 2.29 节完整记录了 AI 模块(V2/V3/V4 变更)
|
||||
- `005_architecture_data.json` 包含 `modules.ai` 节点(exports/dependencies/integrations/safety/streaming/i18n)
|
||||
- **结论:架构图对 AI 模块的记录基本完整**,但未记录跨模块违规依赖(见 2.1.1)。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 架构分层问题
|
||||
|
||||
#### 问题 2.1.1:5 处跨模块直接依赖 `shared/lib/ai`(绕过 modules/ai)
|
||||
|
||||
- **位置**:
|
||||
- [ai-suggest.ts:5](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L5) — `import { createAiChatCompletion } from "@/shared/lib/ai"`
|
||||
- [settings/actions.ts:14](file:///e:/Desktop/CICD/src/modules/settings/actions.ts#L14) — `import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"`
|
||||
- [exams/actions.ts:987](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L987) — 动态 `import("@/shared/lib/ai")`
|
||||
- [exams/ai-pipeline/request.ts:11](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/request.ts#L11) — `import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"`
|
||||
- [exams/ai-pipeline/parse.ts:11](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/parse.ts#L11) — `import { createAiChatCompletion } from "@/shared/lib/ai"`
|
||||
- **原因**:AI 模块在 V2 重构后才形成独立模块,但这些历史调用点未同步迁移。
|
||||
- **后果**:绕过 `modules/ai` 的内容安全过滤、每日配额、Socratic 模式、使用量埋点等保护机制;AI 调用无法统一治理。
|
||||
- **违反规则**:`项目规则 → 架构分层规则 → 模块间只能通过对方 data-access 通信`;`项目规则 → Server Action 规范 → 每个 Action 必须调用 requirePermission()`。
|
||||
|
||||
#### 问题 2.1.2:非流式聊天端点绕过 modules/ai
|
||||
|
||||
- **位置**:[app/api/ai/chat/route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts)
|
||||
- **现状**:直接调用 `shared/lib/ai` 的 `createAiChatCompletion`,未走 `aiChatAction`,缺失内容安全过滤、每日配额、Socratic 模式等保护。
|
||||
- **后果**:与非流式端点(`/api/ai/chat/stream`)形成安全策略不一致;学生可通过非流式端点绕过 Socratic 模式获取直接答案。
|
||||
- **违反规则**:`项目规则 → 安全规范`;`项目规则 → Server Action 规范`。
|
||||
|
||||
#### 问题 2.1.3:4 个子页面重复创建 AiClientService
|
||||
|
||||
- **位置**:
|
||||
- [teacher/lesson-plans/[planId]/edit/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx)
|
||||
- [teacher/homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx)
|
||||
- [student/error-book/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/error-book/page.tsx)
|
||||
- [teacher/exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- **现状**:每个页面各自创建只含 6 个 Action 的 `AiClientService`,覆盖 layout.tsx 的全局 Provider(含 9 个 Action)。
|
||||
- **后果**:代码重复;`generateChildSummary`、`recommendStudyPath`、`getAiUsageStats` 在这些页面内为 `undefined`,若未来组件调用将运行时错误。
|
||||
- **违反规则**:`项目规则 → 工程约定 → 最大化复用`。
|
||||
|
||||
### 2.2 权限问题
|
||||
|
||||
#### 问题 2.2.1:非流式聊天端点未走 requirePermission 体系
|
||||
|
||||
- **位置**:[app/api/ai/chat/route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts)
|
||||
- **现状**:虽然调用了 `requirePermission(Permissions.AI_CHAT)`,但绕过了 `modules/ai/actions.ts` 的 `aiChatAction`,导致内容安全过滤、每日配额、Socratic 模式等保护机制缺失。
|
||||
- **后果**:权限校验通过但安全策略不一致。
|
||||
- **违反规则**:`项目规则 → Server Action 规范 → 每个 Action 必须调用 requirePermission()`(虽调用但绕过 Action 编排层)。
|
||||
|
||||
### 2.3 国际化问题
|
||||
|
||||
#### 问题 2.3.1:ai-chart-renderer.tsx 硬编码中文
|
||||
|
||||
- **位置**:[ai-chart-renderer.tsx:147](file:///e:/Desktop/CICD/src/modules/ai/components/ai-chart-renderer.tsx#L147)
|
||||
- **代码**:`图表数据格式错误,无法渲染`
|
||||
- **后果**:无法切换语言。
|
||||
- **违反规则**:`项目规则 → 所有用户可见文本必须适配 i18n`。
|
||||
|
||||
#### 问题 2.3.2:ai-assistant-widget.tsx systemPrompt 硬编码英文
|
||||
|
||||
- **位置**:[ai-assistant-widget.tsx:230-326](file:///e:/Desktop/CICD/src/modules/ai/components/ai-assistant-widget.tsx#L230-L326)
|
||||
- **现状**:`inferContextFromPath` 函数中所有 `systemPrompt` 和 `contextMessage` 为硬编码英文。
|
||||
- **判定**:systemPrompt 是发送给 AI 的指令(非用户可见文本),可保留英文(模型兼容性最佳);但 `contextMessage` 显示在 UI 中,应 i18n 化。
|
||||
- **后果**:contextMessage 无法国际化。
|
||||
- **违反规则**:`项目规则 → 所有用户可见文本必须适配 i18n`。
|
||||
|
||||
### 2.4 类型安全问题
|
||||
|
||||
#### 问题 2.4.1:ai-markdown-renderer.tsx 使用 as 断言
|
||||
|
||||
- **位置**:[ai-markdown-renderer.tsx:92](file:///e:/Desktop/CICD/src/modules/ai/components/ai-markdown-renderer.tsx#L92)
|
||||
- **代码**:`lang.slice(CHART_LANG_PREFIX.length) as AiChartType`
|
||||
- **后果**:若 lang 不在 AiChartType 枚举内,类型不安全。
|
||||
- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。
|
||||
|
||||
#### 问题 2.4.2:ai-provider-selector.tsx 使用 as 断言
|
||||
|
||||
- **位置**:[ai-provider-selector.tsx:66](file:///e:/Desktop/CICD/src/modules/ai/components/ai-provider-selector.tsx#L66)
|
||||
- **代码**:`field.value as string`
|
||||
- **后果**:react-hook-form 的 field.value 类型应为泛型,此处强转。
|
||||
- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。
|
||||
|
||||
#### 问题 2.4.3:ai-chart-renderer.tsx 使用 as 断言
|
||||
|
||||
- **位置**:[ai-chart-renderer.tsx:117](file:///e:/Desktop/CICD/src/modules/ai/components/ai-chart-renderer.tsx#L117)
|
||||
- **代码**:`JSON.parse(data) as AiChartSpec`
|
||||
- **后果**:JSON 结构不可信,直接断言可能运行时错误。
|
||||
- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。
|
||||
|
||||
### 2.5 错误处理问题
|
||||
|
||||
#### 问题 2.5.1:ai-suggestion-card.tsx 未包裹 Error Boundary
|
||||
|
||||
- **位置**:[ai-suggestion-card.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-suggestion-card.tsx)
|
||||
- **现状**:组件内部有 try/catch 处理异步错误,但未用 `AiErrorBoundary` 包裹渲染期错误。
|
||||
- **后果**:若 `result.data` 结构异常或 `questions.map` 渲染时抛错,整页崩溃。
|
||||
- **违反规则**:`审计要求 → 每个独立数据区块必须用 React Error Boundary 包裹`。
|
||||
|
||||
#### 问题 2.5.2:非流式聊天端点错误处理不完整
|
||||
|
||||
- **位置**:[app/api/ai/chat/route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts)
|
||||
- **现状**:`getStatusFromError` 基于错误消息字符串匹配状态码,脆弱且不可靠。
|
||||
- **后果**:错误分类不准确。
|
||||
- **违反规则**:`项目规则 → 错误处理`。
|
||||
|
||||
### 2.6 文件大小问题
|
||||
|
||||
#### 问题 2.6.1:use-floating-ball.ts 超出 Hook 行数限制
|
||||
|
||||
- **位置**:[use-floating-ball.ts](file:///e:/Desktop/CICD/src/modules/ai/hooks/use-floating-ball.ts)
|
||||
- **现状**:243 行,超出 Hook 文件 80 行建议上限 163 行。
|
||||
- **后果**:职责过多(位置加载/保存、拖拽事件、边缘吸附、半隐藏),难以维护和测试。
|
||||
- **违反规则**:`项目规则 → 单文件行数 → 自定义 Hook:建议 ≤ 80 行`。
|
||||
|
||||
### 2.7 可复用性问题
|
||||
|
||||
#### 问题 2.7.1:4 个子页面重复创建 AiClientService
|
||||
|
||||
- 见问题 2.1.3。
|
||||
|
||||
#### 问题 2.7.2:ai-suggestion-card.tsx 未被使用
|
||||
|
||||
- **位置**:[ai-suggestion-card.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-suggestion-card.tsx)
|
||||
- **现状**:组件已实现但无任何引用。
|
||||
- **后果**:死代码,维护负担。
|
||||
- **违反规则**:`审计要求 → 最大化复用`(应集成到错题本等页面)。
|
||||
|
||||
### 2.8 监控问题
|
||||
|
||||
#### 问题 2.8.1:非流式聊天端点无使用量埋点
|
||||
|
||||
- **位置**:[app/api/ai/chat/route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts)
|
||||
- **现状**:未调用 `trackAiUsage`,调用数据不入统计。
|
||||
- **后果**:管理员仪表盘数据不完整。
|
||||
- **违反规则**:`审计要求 → 监控`。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与 Khanmigo(Khan Academy)的差距
|
||||
|
||||
| 功能 | Khanmigo | 本项目 | 差距 |
|
||||
|------|----------|--------|------|
|
||||
| 学生 Socratic 模式 | ✅ 强制引导式 | ✅ 已实现 | 无 |
|
||||
| 教师备课助手 | ✅ 课程计划生成 | ✅ 已实现 | 无 |
|
||||
| 多语言支持 | ✅ 20+ 语言 | ⚠️ 仅中英文 | 缺少多语言 Prompt |
|
||||
| 学生情绪识别 | ✅ 检测挫败感 | ❌ 未实现 | 缺少情绪分析 |
|
||||
| 家长沟通建议 | ✅ 家庭教育指导 | ✅ 已实现 | 无 |
|
||||
|
||||
### 3.2 与 Duolingo Max 的差距
|
||||
|
||||
| 功能 | Duolingo Max | 本项目 | 差距 |
|
||||
|------|-------------|--------|------|
|
||||
| 解释我的答案 | ✅ AI 解释错误原因 | ❌ 未实现 | 缺少错题 AI 解释 |
|
||||
| 角色扮演练习 | ✅ 情景对话练习 | ❌ 未实现 | 缺少口语/情景练习 |
|
||||
| 个性化复习 | ✅ 基于遗忘曲线 | ⚠️ 仅 SM2 算法 | AI 未参与复习规划 |
|
||||
|
||||
### 3.3 与 Squirrel AI 的差距
|
||||
|
||||
| 功能 | Squirrel AI | 本项目 | 差距 |
|
||||
|------|-------------|--------|------|
|
||||
| 纳米级知识图谱 | ✅ 10000+ 知识点 | ⚠️ V3 已集成 | 知识图谱粒度较粗 |
|
||||
| 自适应学习路径 | ✅ 实时调整 | ✅ V3 已实现 | 无 |
|
||||
| 多模态学习 | ✅ 视频+图文+音频 | ❌ 仅文本 | 缺少多模态 |
|
||||
| 学习风格识别 | ✅ VARK 模型 | ❌ 未实现 | 缺少学习风格分析 |
|
||||
|
||||
### 3.4 与 MagicSchool AI 的差距
|
||||
|
||||
| 功能 | MagicSchool AI | 本项目 | 差距 |
|
||||
|------|----------------|--------|------|
|
||||
| 50+ AI 工具 | ✅ 丰富工具集 | ⚠️ 8 个能力 | 工具数量不足 |
|
||||
| IEP 生成 | ✅ 特殊教育计划 | ❌ 未实现 | 缺少 IEP |
|
||||
| 家校沟通模板 | ✅ 邮件/通知模板 | ❌ 未实现 | 缺少沟通模板 |
|
||||
| 跨学科项目设计 | ✅ PBL 项目设计 | ❌ 未实现 | 缺少 PBL |
|
||||
|
||||
### 3.5 关键差距总结
|
||||
|
||||
1. **AI 能力数量不足**:仅 8 个能力,行业平均 15-20 个
|
||||
2. **多模态缺失**:仅支持文本,缺少图像/语音/视频输入
|
||||
3. **学习风格识别缺失**:未识别学生 VARK 学习风格
|
||||
4. **情绪识别缺失**:未检测学生挫败感/兴奋度
|
||||
5. **IEP/PBL 缺失**:未支持特殊教育和项目式学习
|
||||
6. **跨模块数据联动不足**:AI 未与考勤、行为、心理等数据联动分析
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急 — 安全/架构合规)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | 5 处跨模块直接依赖 shared/lib/ai | 迁移至通过 modules/ai 的 data-access 或 actions 调用 |
|
||||
| P0-2 | 非流式聊天端点绕过 modules/ai | 重构为调用 aiChatAction 或迁移安全策略 |
|
||||
| P0-3 | ai-chart-renderer.tsx 硬编码中文 | 迁移至 i18n |
|
||||
| P0-4 | 3 处 as 断言 | 改用类型守卫函数 |
|
||||
|
||||
### P1(重要 — 规范/复用)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | 4 个子页面重复创建 AiClientService | 提取 createAiClientService 工厂函数 |
|
||||
| P1-2 | use-floating-ball.ts 超出行数限制 | 拆分为 use-drag-position + use-edge-snap |
|
||||
| P1-3 | ai-suggestion-card.tsx 未包裹 Error Boundary | 集成时用 AiErrorBoundary 包裹 |
|
||||
| P1-4 | ai-assistant-widget.tsx contextMessage 硬编码 | 迁移至 i18n |
|
||||
| P1-5 | 非流式端点无使用量埋点 | 集成 trackAiUsage |
|
||||
|
||||
### P2(增强 — 行业对标)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 缺少错题 AI 解释 | 新增 explainErrorAction |
|
||||
| P2-2 | 缺少学习风格识别 | 新增 VARK 评估 |
|
||||
| P2-3 | 缺少 IEP 生成 | 新增特殊教育模块 |
|
||||
| P2-4 | 缺少家校沟通模板 | 新增沟通模板生成 |
|
||||
| P2-5 | 缺少多模态输入 | 支持图像/语音输入 |
|
||||
| P2-6 | 缺少情绪识别 | 集成情绪分析 API |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 需补充的节点
|
||||
|
||||
1. **跨模块违规依赖**:在 `005_architecture_data.json` 的 `dependencyMatrix` 中标注 `lesson-preparation`、`settings`、`exams` 对 `shared/lib/ai` 的违规依赖(应改为通过 `modules/ai`)。
|
||||
2. **非流式端点安全策略缺失**:在 `004_architecture_impact_map.md` 的 AI 模块安全机制章节补充非流式端点的安全策略差距。
|
||||
3. **ai-suggestion-card.tsx 未使用**:在文件清单中标注该组件为"已实现未集成"。
|
||||
|
||||
### 5.2 需修改的节点
|
||||
|
||||
1. **V4 超时优化**:已在 V4 中记录(`shared/lib/ai/client.ts` 按场景分离超时)。
|
||||
2. **V4 规范修复**:已在 V4 中记录(ai-service.ts 消除 as 断言、actions.ts 消除非空断言、ai-assistant-widget.tsx i18n 修复)。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施记录
|
||||
|
||||
### 6.1 P0 改进实施
|
||||
|
||||
#### P0-1:迁移跨模块依赖(部分)
|
||||
|
||||
**已实施**:
|
||||
- 修复 `ai-chart-renderer.tsx` 硬编码中文(P0-3)
|
||||
- 修复 `ai-markdown-renderer.tsx` as 断言(P0-4 部分)
|
||||
- 修复 `ai-provider-selector.tsx` as 断言(P0-4 部分)
|
||||
- 修复 `ai-chart-renderer.tsx` as 断言(P0-4 部分)
|
||||
|
||||
**未实施(需中长期计划)**:
|
||||
- 5 处跨模块直接依赖 shared/lib/ai 的迁移(涉及 exams/lesson-preparation/settings 三个模块的重构,影响范围大,需单独排期)
|
||||
|
||||
#### P0-2:非流式聊天端点安全策略重构(已实施)
|
||||
|
||||
**实施**:重构 `app/api/ai/chat/route.ts`,与流式端点 `/api/ai/chat/stream` 安全策略完全对齐:
|
||||
- Zod 校验输入(`AiChatInputSchema`,限制消息数 50/长度 8000)
|
||||
- `tryConsumeDailyQuota` 原子化每日限额(防 TOCTOU 竞态)
|
||||
- `filterUserInput` 输入安全过滤
|
||||
- `filterAiOutput` 输出安全过滤
|
||||
- 学生侧 Socratic 模式(服务端强制 `SOCRATIC_TUTOR_SYSTEM_PROMPT`,忽略客户端 systemPrompt)
|
||||
- `validateSocraticOutput` 苏格拉底式输出校验
|
||||
- 过滤/失败时 `refundDailyQuota`(不惩罚用户)
|
||||
- `trackEvent` 使用量埋点(成功/失败均记录)
|
||||
|
||||
#### P0-3:ai-chart-renderer.tsx 硬编码中文(已实施)
|
||||
|
||||
**实施**:添加 i18n 键 `ai.chart.parseError`,替换硬编码中文。
|
||||
|
||||
#### P0-4:as 断言修复(已实施)
|
||||
|
||||
**实施**:
|
||||
- `ai-markdown-renderer.tsx`:改用类型守卫函数 `isAiChartType`
|
||||
- `ai-provider-selector.tsx`:改用 `String(field.value ?? "")`
|
||||
- `ai-chart-renderer.tsx`:改用 Zod schema 校验
|
||||
|
||||
### 6.2 P1 改进实施
|
||||
|
||||
#### P1-1:提取 createAiClientService 工厂(已实施)
|
||||
|
||||
**实施**:新建 `modules/ai/context/create-ai-client-service.ts`,导出 `createFullAiClientService`(含全部 9 个 Action)和 `createCoreAiClientService`(仅 6 个常用 Action)两个工厂函数。`app/(dashboard)/layout.tsx` 使用前者,4 个子页面(error-book、lesson-plans、homework、exams)使用后者,消除重复代码。
|
||||
|
||||
#### P1-2:拆分 use-floating-ball.ts(已实施)
|
||||
|
||||
**实施**:将 243 行的 `use-floating-ball.ts` 拆分为 3 个文件:
|
||||
- `use-position-persistence.ts`:Position 类型、常量、clamp/load/save 纯函数、位置状态 Hook(localStorage + resize 校正)
|
||||
- `use-drag-position.ts`:拖拽状态 + pointer 事件处理 Hook(通过回调委托业务逻辑)
|
||||
- `use-floating-ball.ts`:主组合 Hook(边缘吸附 + 半隐藏 + hovered 状态 + show/resetPosition)
|
||||
|
||||
#### P1-3:ai-suggestion-card.tsx Error Boundary(已实施)
|
||||
|
||||
**实施**:将原组件重命名为 `AiSuggestionCardInner`,新建 `AiSuggestionCard` 包装器用 `AiErrorBoundary` 包裹内部组件,保持公开 API 不变。
|
||||
|
||||
#### P1-4:ai-assistant-widget.tsx contextMessage i18n(已实施)
|
||||
|
||||
**实施**:在 `en/ai.json` 和 `zh-CN/ai.json` 中添加 `chat.contextMessage.*` 翻译键(7 个场景:teacherGrading/teacherLesson/teacherExam/studentErrorBook/studentHomework/parent/admin),替换 `inferContextFromPath` 中 7 处硬编码英文。
|
||||
|
||||
#### P1-5:非流式端点使用量埋点(已实施)
|
||||
|
||||
**实施**:在 `app/api/ai/chat/route.ts` 中集成 `trackEvent`(事件名 `ai.chat`),成功时记录 durationMs/tokenCount,失败时记录 errorMessage/durationMs。与流式端点(`ai.chat_stream`)对齐。
|
||||
|
||||
### 6.3 P2 改进(中长期计划)
|
||||
|
||||
#### P2-1:错题 AI 解释(已实施)
|
||||
|
||||
**实施**:
|
||||
- 新增 `ExplainErrorInput`/`ExplainErrorResult` 类型(`modules/ai/types.ts`)
|
||||
- 新增 `ExplainErrorInputSchema`/`ExplainErrorResultSchema` Zod 校验(`modules/ai/schema.ts`)
|
||||
- 新增 `EXPLAIN_ERROR_SYSTEM_PROMPT` 提示词(`modules/ai/services/prompt-templates.ts`)
|
||||
- 新增 `explainError` 服务方法(`modules/ai/services/ai-service.ts`)
|
||||
- 新增 `explainErrorAction` Server Action(`modules/ai/actions.ts`,权限:AI_CHAT + ERROR_BOOK_READ)
|
||||
- 更新 `AiService`/`AiClientService` 接口,添加 `explainError` 方法
|
||||
- 更新 `createFullAiClientService` 工厂函数包含 `explainError`
|
||||
- 更新 `AiCapability` 类型添加 `"explain-error"`
|
||||
- 更新 `AiUsageEvent`/`AI_EVENT_MAP` 添加 `explain_error` 埋点
|
||||
- 更新 i18n 翻译键 `capability.explainError`(en/zh-CN)
|
||||
- 更新架构文档 004/005
|
||||
|
||||
**未实施(需后续排期)**:
|
||||
- P2-2:VARK 学习风格评估(需新增评估模块 + DB 表)
|
||||
- P2-3:IEP 特殊教育计划生成(需新增特殊教育模块)
|
||||
- P2-4:家校沟通模板生成(需新增模板管理模块)
|
||||
- P2-5:多模态输入支持(需接入图像/语音 API)
|
||||
- P2-6:情绪识别(需接入情绪分析 API)
|
||||
392
docs/architecture/audit/announcements-audit-report.md
Normal file
392
docs/architecture/audit/announcements-audit-report.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# 公告(announcements)模块审计报告
|
||||
|
||||
> 审查日期:2026-06-25
|
||||
> 审查范围:`src/modules/announcements/**`、`src/app/(dashboard)/announcements/**`、`src/app/(dashboard)/admin/announcements/**`、`src/shared/i18n/messages/{zh-CN,en}/announcements.json`
|
||||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.16、`docs/architecture/005_architecture_data.json#announcements`
|
||||
> 关联报告:`announcements-messages-audit-report.md`(合并版,2026-06-22,已不再维护,本文为公告模块的独立深度审计)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 路径 | 文件 | 行数 | 说明 |
|
||||
|----|------|------|------|------|
|
||||
| 路由 · 用户端 | `src/app/(dashboard)/announcements/page.tsx` | 1 | 36 | 列表页(所有非管理角色共用) |
|
||||
| 路由 · 用户端 | `src/app/(dashboard)/announcements/[id]/page.tsx` | 1 | 41 | 详情页(只读) |
|
||||
| 路由 · 用户端 | `src/app/(dashboard)/announcements/{loading,error,[id]/error}.tsx` | 3 | 60 | 骨架屏 + 错误边界 |
|
||||
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/page.tsx` | 1 | 45 | 管理列表页 |
|
||||
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/[id]/page.tsx` | 1 | 47 | 编辑页(直接渲染表单,无详情视图) |
|
||||
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/{loading,error,[id]/error}.tsx` | 3 | 64 | 骨架屏 + 错误边界 |
|
||||
| 模块 | `src/modules/announcements/actions.ts` | 1 | 403 | 9 个 Server Action + 通知编排 + 埋点 |
|
||||
| 模块 | `src/modules/announcements/data-access.ts` | 1 | 413 | CRUD + 发布/归档 + 置顶/已读 + 3 个页面编排函数 |
|
||||
| 模块 | `src/modules/announcements/types.ts` | 1 | 75 | 类型定义 |
|
||||
| 模块 | `src/modules/announcements/schema.ts` | 1 | 95 | Zod 校验 + `refineAudience` 条件校验 |
|
||||
| 模块 · 组件 | `src/modules/announcements/components/announcement-list.tsx` | 1 | 126 | 列表(纯服务端过滤) |
|
||||
| 模块 · 组件 | `src/modules/announcements/components/announcement-card.tsx` | 1 | 125 | 卡片 + 置顶切换 |
|
||||
| 模块 · 组件 | `src/modules/announcements/components/announcement-detail.tsx` | 1 | 267 | 详情 + 管理操作 + 自动已读 |
|
||||
| 模块 · 组件 | `src/modules/announcements/components/announcement-form.tsx` | 1 | 230 | 创建/编辑表单 |
|
||||
| 模块 · 组件 | `src/modules/announcements/components/admin-announcements-view.tsx` | 1 | 67 | 管理端视图(列表 + 创建 Dialog) |
|
||||
| i18n | `src/shared/i18n/messages/{zh-CN,en}/announcements.json` | 2 | 103/103 | 11 命名空间翻译字典 |
|
||||
| 测试 | — | 0 | 0 | **零测试文件** |
|
||||
|
||||
文件大小均在规范内(组件 ≤500 行,actions/data-access ≤800 行)。
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
[Route] /announcements/page.tsx
|
||||
└─▶ announcements/data-access.getUserAnnouncementsPageData(userId, dataScope)
|
||||
├─▶ resolveAudience(userId, dataScope) // 内部函数
|
||||
│ └─▶ classes/data-access.{getClassGradeId | getStudentActiveClassId | getStudentActiveGradeId}
|
||||
└─▶ getAnnouncements({ status: "published", audience })
|
||||
|
||||
[Route] /announcements/[id]/page.tsx ⚠️ 未做受众/状态过滤
|
||||
├─▶ announcements/data-access.getAnnouncementById(id)
|
||||
└─▶ announcements/data-access.isAnnouncementReadByUser(id, userId)
|
||||
|
||||
[Route] /admin/announcements/page.tsx
|
||||
└─▶ announcements/data-access.getAdminAnnouncementsPageData(status)
|
||||
├─▶ getAnnouncements({ status })
|
||||
├─▶ school/data-access.getGrades()
|
||||
└─▶ classes/data-access.getAdminClasses()
|
||||
|
||||
[Route] /admin/announcements/[id]/page.tsx
|
||||
└─▶ announcements/data-access.getEditAnnouncementPageData(id)
|
||||
├─▶ getAnnouncementById(id)
|
||||
└─▶ school/data-access.getGrades()
|
||||
|
||||
[Action] createAnnouncementAction / updateAnnouncementAction / publishAnnouncementAction
|
||||
└─▶ notifyAnnouncementPublished(announcement)
|
||||
├─▶ resolveTargetUserIds(announcement) // ⚠️ 纯业务逻辑在 actions.ts
|
||||
│ ├─▶ users/data-access.{getAllUserIds | getUserIdsByGradeId}
|
||||
│ └─▶ classes/data-access.{getStudentIdsByClassId | getTeacherIdsByClassIds}
|
||||
└─▶ notifications.sendBatchNotifications(payloads)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
`004_architecture_impact_map.md` §2.16 对 announcements 模块的记录较为完整:
|
||||
- ✅ 导出函数(9 个 Action + 14 个 data-access 函数)记录准确
|
||||
- ✅ 依赖关系(`shared/*`、`@/auth`、`school`、`classes`、`users`、`notifications`)记录准确
|
||||
- ✅ 已修复问题清单(P1-2/P1-5/P1-6/V2-P0-2/V2-P1-1/V2-P1-4/V2-P2-13d/V3-P0-2)记录详实
|
||||
- ✅ 文件清单与组件清单行数准确
|
||||
|
||||
**但架构图存在以下遗漏/不一致**(详见第五章):
|
||||
1. 未记录 `AnnouncementDetail` 组件中 `canManage=true` 分支为**死代码**(无任何页面使用)
|
||||
2. 未记录 `getAnnouncementReadStatusAction` 为**死代码**(无任何调用方)
|
||||
3. 未记录 `/announcements/[id]` 路由层存在的**安全越权风险**(无受众过滤)
|
||||
4. 未记录 `resolveAudience` 内部函数的**多孩子/多年级数据截断 Bug**
|
||||
5. 未记录 actions 返回的英文字符串未走 i18n 的问题
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 【P0 · 安全越权】用户端详情页无受众/状态过滤
|
||||
|
||||
- **位置**:[src/app/(dashboard)/announcements/[id]/page.tsx:26-27](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/[id]/page.tsx)
|
||||
- **问题**:详情页直接调用 `getAnnouncementById(id)`,未传入 `audience` 也未校验 `status === "published"`。
|
||||
- **后果**:任意持有 `ANNOUNCEMENT_READ` 权限的登录用户,只要知道/猜到公告 ID(cuid2),即可读取:
|
||||
- 草稿(`status="draft"`)公告——提前泄露未发布内容
|
||||
- 已归档(`status="archived"`)公告——绕过归档语义
|
||||
- 其他年级/班级的定向公告——跨班级信息泄露(如某班处分通知被外班学生读到)
|
||||
- **违反规则**:项目规则"安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" + "Parent routes must include permission checks with both `parentId` and `studentId` to prevent information leakage"。
|
||||
- **根因**:`getAnnouncementById` 设计为通用读取函数,未提供"按受众过滤"重载;路由层也未在读取后做二次校验。
|
||||
|
||||
### 2.2 【P0 · 数据截断】`resolveAudience` 仅取首个 gradeId / classId / childId
|
||||
|
||||
- **位置**:[src/modules/announcements/data-access.ts:351-395](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
|
||||
- **问题**:
|
||||
```ts
|
||||
if (dataScope.type === "grade_managed") {
|
||||
const gradeId = dataScope.gradeIds[0] // ⚠️ 仅取第一个
|
||||
}
|
||||
if (dataScope.type === "class_members" || dataScope.type === "class_taught") {
|
||||
const classId = dataScope.classIds[0] // ⚠️ 仅取第一个
|
||||
}
|
||||
if (dataScope.type === "children") {
|
||||
const childId = dataScope.childrenIds[0] // ⚠️ 仅取第一个孩子
|
||||
}
|
||||
```
|
||||
- **后果**:
|
||||
- **家长**有多个孩子在不同班级/年级时,只能看到第一个孩子的定向公告,第二个孩子的班主任通知完全不可见——直接违反 K12 家长端核心诉求。
|
||||
- **年级主任**管理多个年级时,只能看到第一个年级的公告。
|
||||
- **教师**任课多个班级时,只能看到第一个班级的公告。
|
||||
- **违反规则**:项目规则"Parent routes must include permission checks with both `parentId` and `studentId`" 与"data-access 层结合当前用户权限过滤"。
|
||||
- **根因**:`getAnnouncements` 的 `audience` 参数设计为单值 `{ gradeId?, classId? }`,不支持多值;`resolveAudience` 为迁就该签名做了截断。
|
||||
|
||||
### 2.3 【P0 · 越权写】置顶/已读 Action 缺少资源所有权二次校验
|
||||
|
||||
- **位置**:[src/modules/announcements/actions.ts:335-376](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
|
||||
- **问题**:
|
||||
- `toggleAnnouncementPinAction` 仅校验 `ANNOUNCEMENT_MANAGE`,未校验公告是否存在、未校验调用者是否为该公告作者或管理员范围。
|
||||
- `markAnnouncementAsReadAction` 仅校验 `ANNOUNCEMENT_READ`,未校验该公告是否对当前用户可见(即未结合 2.1 的受众过滤)。任意用户可对任意公告 ID(包括草稿、他人班级公告)写入已读记录,污染 `announcement_reads` 表。
|
||||
- **后果**:数据库完整性被破坏;统计 `readCount` 失真;为后续基于已读率的分析埋下错误数据。
|
||||
- **违反规则**:项目规则"Server Action 二次校验"。
|
||||
- **根因**:Action 层信任了 `requirePermission` 的角色校验,未做资源级(resource-level)授权。
|
||||
|
||||
### 2.4 【P1 · i18n 违规】Actions 返回英文硬编码消息
|
||||
|
||||
- **位置**:[src/modules/announcements/actions.ts](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts) 全文
|
||||
- **问题**:所有 Action 返回的 `ActionState.message` 均为英文字符串:
|
||||
- `"Announcement created"` / `"Announcement updated"` / `"Announcement deleted"`
|
||||
- `"Announcement published"` / `"Announcement archived"`
|
||||
- `"Announcement not found"` / `"Invalid form data"` / `"Unexpected error"`
|
||||
- `"Pin status toggled"` / `"Announcement marked as read"`
|
||||
- 这些 message 通过 `toast.success(res.message)` / `toast.error(res.message)` 直接展示给用户(见 [announcement-detail.tsx:80,100,115](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx) 与 [announcement-form.tsx:80,88](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx))。
|
||||
- **后果**:中文用户在创建/发布/删除公告后看到英文 Toast,i18n 字典中已定义的 `messages.created` / `messages.updated` 等翻译键完全未使用。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n" + "Server Action 返回值统一采用 `ActionState<T>` 类型"(隐含 message 应可本地化)。
|
||||
- **根因**:Actions 在 try 块内同步返回字符串,未通过 `getTranslations("announcements")` 获取本地化文案;i18n 字典定义了键但 Action 未消费。
|
||||
|
||||
### 2.5 【P1 · 死代码】`AnnouncementDetail` 管理分支与 `getAnnouncementReadStatusAction` 无调用方
|
||||
|
||||
- **位置**:
|
||||
- [src/modules/announcements/components/announcement-detail.tsx:165-200](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx)(`canManage` 为 true 时的发布/归档/删除/置顶/编辑按钮组)
|
||||
- [src/modules/announcements/actions.ts:381-391](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)(`getAnnouncementReadStatusAction`)
|
||||
- **问题**:
|
||||
- 全仓搜索 `AnnouncementDetail` 的使用方,仅 [src/app/(dashboard)/announcements/[id]/page.tsx:34-38](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/[id]/page.tsx) 一处,且 `canManage={false}`。管理端 `/admin/announcements/[id]` 直接渲染 `AnnouncementForm`(编辑模式),**没有管理端详情页**。
|
||||
- 全仓搜索 `getAnnouncementReadStatusAction`,**零调用方**。该 Action 返回 `Record<string,boolean>`,本应用于列表页批量标记已读/未读,但列表页从未调用。
|
||||
- **后果**:
|
||||
- 管理员无法在 UI 中执行发布/归档/删除/置顶操作(除非进入编辑表单),严重限制了管理端可用性。
|
||||
- `announcement.readCount` 字段在 `AnnouncementDetail` 中展示,但因 `canManage` 永远为 false,**用户永远看不到已读人数**——已读统计功能在 UI 层完全不可见。
|
||||
- 列表页公告卡片没有"已读/未读"视觉区分,已读回执的数据无法驱动 UI。
|
||||
- **违反规则**:项目规则"避免 backwards-compatibility hacks ... 如果确定未使用,应完全删除" + "识别四个角色共用的 UI 块"。
|
||||
- **根因**:管理端路由设计遗漏了详情视图;已读状态查询 Action 未被列表组件消费。
|
||||
|
||||
### 2.6 【P1 · 耦合】组件直接 import actions,未通过 Context/Provider 注入
|
||||
|
||||
- **位置**:
|
||||
- [announcement-card.tsx:13](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx):`import { toggleAnnouncementPinAction } from "../actions"`
|
||||
- [announcement-detail.tsx:25-31](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx):`import { archiveAnnouncementAction, deleteAnnouncementAction, markAnnouncementAsReadAction, publishAnnouncementAction, toggleAnnouncementPinAction } from "../actions"`
|
||||
- [announcement-form.tsx:21](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx):`import { createAnnouncementAction, updateAnnouncementAction } from "../actions"`
|
||||
- **问题**:组件硬编码依赖具体 Server Action,无法在不修改组件代码的前提下替换为 mock 实现。
|
||||
- **后果**:
|
||||
- 组件不可单元测试(必须 mock 整个 `../actions` 模块)。
|
||||
- 无法为不同角色注入不同实现(如家长端只读、教师端可编辑班级公告)。
|
||||
- 未来若要将公告组件复用于"班级空间"或"家长端聚合页",必须重写组件。
|
||||
- **违反规则**:用户要求"完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
|
||||
- **根因**:组件设计未遵循依赖注入原则。
|
||||
|
||||
### 2.7 【P1 · 耦合】`AnnouncementForm` 硬编码路由跳转
|
||||
|
||||
- **位置**:[announcement-form.tsx:82,217](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx)
|
||||
- **问题**:表单提交成功后 `router.push("/admin/announcements")`,取消按钮也跳转到 `/admin/announcements`。
|
||||
- **后果**:表单无法在管理端以外的场景复用(如教师端发布班级公告、嵌入到班级详情页的快速发布公告入口)。
|
||||
- **违反规则**:用户要求"组合优先 ... 逻辑复用一律抽取为自定义 hooks" + "最大化复用"。
|
||||
- **根因**:表单未通过 `onSuccess` / `onCancel` 回调或 `successHref` prop 解耦导航。
|
||||
|
||||
### 2.8 【P1 · 业务逻辑位置】`resolveTargetUserIds` 放在 actions.ts
|
||||
|
||||
- **位置**:[src/modules/announcements/actions.ts:48-66](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
|
||||
- **问题**:受众解析 + 用户 ID 聚合是纯业务逻辑(无 I/O 副作用之外的逻辑),却放在 Server Action 文件中,与 Action 编排逻辑混杂。
|
||||
- **后果**:
|
||||
- 无法独立单元测试(必须 mock `getAllUserIds` / `getStudentIdsByClassId` 等跨模块 data-access)。
|
||||
- 与 `data-access.ts` 中的 `resolveAudience` 形成两套受众解析逻辑,职责重叠。
|
||||
- **违反规则**:项目规则"可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离" + "Server Actions / Data Access 模块:建议 ≤ 800 行 ... 超过应考虑拆分"。
|
||||
- **根因**:actions.ts 既承担 HTTP 编排又承担业务规则,未分离 service 层。
|
||||
|
||||
### 2.9 【P1 · 性能】`toggleAnnouncementPin` 与 `markAnnouncementAsRead` 非原子操作
|
||||
|
||||
- **位置**:[src/modules/announcements/data-access.ts:210-224,234-250](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
|
||||
- **问题**:
|
||||
- `toggleAnnouncementPin`:先 `SELECT isPinned`,再 `UPDATE`。两次 DB 往返,且在并发场景下存在 lost update(两个管理员同时切换会得到错误结果)。
|
||||
- `markAnnouncementAsRead`:先 `SELECT id`,再 `INSERT`。已有唯一索引保证幂等,但多一次 SELECT 浪费往返。
|
||||
- **后果**:高并发时数据不一致;DB 负载翻倍。
|
||||
- **违反规则**:项目规则"性能:优先使用 React Server Components"(隐含高效数据访问)。
|
||||
- **根因**:未使用 Drizzle 的 `sql` 表达式或 `onDuplicateKeyUpdate`/`INSERT IGNORE` 语义。
|
||||
|
||||
### 2.10 【P1 · 错误处理】`handleActionError` 吞错误上下文
|
||||
|
||||
- **位置**:[src/modules/announcements/actions.ts:34-40](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
|
||||
- **问题**:
|
||||
```ts
|
||||
function handleActionError(e: unknown): ActionState<never> {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
```
|
||||
- 未 `console.error` 记录错误堆栈,生产环境无法定位故障。
|
||||
- 直接把 `e.message` 返回给前端,可能泄露内部错误信息(如 SQL 错误)。
|
||||
- "Unexpected error" 为英文硬编码。
|
||||
- **后果**:可观测性差;安全信息泄露风险。
|
||||
- **违反规则**:项目规则"错误与边界处理" + "i18n 就绪"。
|
||||
- **根因**:错误处理未与日志/埋点/i18n 集成。
|
||||
|
||||
### 2.11 【P2 · a11y】置顶按钮嵌套在 `<Link>` 内的键盘交互问题
|
||||
|
||||
- **位置**:[announcement-card.tsx:80-92,116-121](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx)
|
||||
- **问题**:`AnnouncementCard` 在有 `href` 时用 `<Link>` 包裹整个卡片,同时卡片内的"置顶"按钮是一个 `<button>`。`handleTogglePin` 调用 `e.preventDefault()` + `e.stopPropagation()` 处理鼠标点击,但:
|
||||
- 键盘聚焦到置顶按钮后按 `Enter`,部分浏览器会同时触发外层 `<a>` 的导航。
|
||||
- 屏幕阅读器会朗读"链接 标题",但置顶按钮的 `aria-label` 在链接上下文中语义模糊。
|
||||
- **后果**:键盘用户可能误跳转;a11y 不达标。
|
||||
- **违反规则**:项目规则"可访问性(a11y):语义化标签、ARIA 属性、键盘导航"。
|
||||
- **根因**:交互按钮不应嵌套在导航链接内;应使用"卡片头部可点击 + 操作按钮独立"的布局。
|
||||
|
||||
### 2.12 【P2 · 类型不安全】`mapRow` 内联对象类型与 schema 脱钩
|
||||
|
||||
- **位置**:[src/modules/announcements/data-access.ts:23-53](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
|
||||
- **问题**:`mapRow` 的参数类型是手写的内联对象,未使用 Drizzle 推导类型 `typeof announcements.$inferSelect`。
|
||||
- **后果**:schema 变更(如新增字段)时,`mapRow` 不会在编译期报错,导致类型漂移。
|
||||
- **违反规则**:项目规则"TypeScript 严格模式 ... 函数返回值必须显式标注"。
|
||||
- **根因**:未利用 Drizzle 的类型推导能力。
|
||||
|
||||
### 2.13 【P2 · i18n 字典冗余/缺失并存】
|
||||
|
||||
- **位置**:[src/shared/i18n/messages/zh-CN/announcements.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/announcements.json)
|
||||
- **问题**:
|
||||
- 已定义但未使用的键:`messages.created` / `messages.updated` / `messages.deleted` / `messages.published` / `messages.archived` / `messages.notFound` / `messages.createFailed` / `messages.invalidForm` / `messages.markedRead`(共 9 个死键,因 actions 未消费)。
|
||||
- 缺失的键:`description.detail`(详情页描述)、`description.create`(创建 Dialog 描述)。
|
||||
- **后果**:i18n 字典维护成本上升;新增页面时找不到对应键。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **根因**:i18n 键与代码未做同步校验。
|
||||
|
||||
### 2.14 【P2 · 死分支】`detailHrefBuilder` prop 未被使用
|
||||
|
||||
- **位置**:[announcement-list.tsx:39,70-74](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx)
|
||||
- **问题**:`AnnouncementList` 同时支持 `detailHrefPrefix`(字符串前缀)和 `detailHrefBuilder`(函数)两种 prop,但全仓搜索 `detailHrefBuilder` 的传入方为零(所有调用方都使用 `detailHrefPrefix`)。
|
||||
- **后果**:死代码增加维护负担。
|
||||
- **违反规则**:项目规则"避免 backwards-compatibility hacks"。
|
||||
- **根因**:V3 重构引入 `detailHrefPrefix` 后未清理旧 prop。
|
||||
|
||||
### 2.15 【P2 · 重复骨架屏】用户端与管理端 loading.tsx 完全重复
|
||||
|
||||
- **位置**:
|
||||
- [src/app/(dashboard)/announcements/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/loading.tsx)
|
||||
- [src/app/(dashboard)/admin/announcements/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/announcements/loading.tsx)
|
||||
- **问题**:两个文件几乎逐行重复(仅管理端多一个"新建公告"按钮骨架),未抽取共享骨架屏组件。
|
||||
- **后果**:UI 调整需改两处。
|
||||
- **违反规则**:项目规则"Shared components must be extracted when page duplication exceeds 90%"。
|
||||
- **根因**:未识别到骨架屏也是可复用 UI 块。
|
||||
|
||||
### 2.16 【P2 · 无分页 UI】`getAnnouncements` 支持分页但 UI 未消费
|
||||
|
||||
- **位置**:[data-access.ts:55-112](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts) 支持 `page` / `pageSize`;[announcement-list.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx) 无分页控件。
|
||||
- **问题**:列表页默认 `pageSize=20`,超过 20 条公告时静默截断,用户无法翻页。
|
||||
- **后果**:历史公告不可访问;K12 学校一学期公告数通常 > 20。
|
||||
- **违反规则**:用户要求"可扩展性:配置驱动设计"。
|
||||
- **根因**:分页参数未贯穿到 UI。
|
||||
|
||||
### 2.17 【P2 · 表单与 Dialog 行为冲突】
|
||||
|
||||
- **位置**:[admin-announcements-view.tsx:57-64](file:///e:/Desktop/CICD/src/modules/announcements/components/admin-announcements-view.tsx)
|
||||
- **问题**:`AdminAnnouncementsView` 在 Dialog 中渲染 `AnnouncementForm`(创建模式)。但 `AnnouncementForm` 的 Cancel 按钮 `router.push("/admin/announcements")` 会触发整页跳转,而不是关闭 Dialog。提交成功后也是 `router.push` 而非 `onSuccess` 回调。
|
||||
- **后果**:用户体验割裂(Dialog 内按钮触发路由跳转);`handleOpenChange` 中的 `router.refresh()` 与表单跳转重复。
|
||||
- **违反规则**:项目规则"组合优先 ... 严禁使用继承或深层嵌套 HOC"。
|
||||
- **根因**:表单未与容器解耦。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
参考 Google Classroom、钉钉教育、企业微信家校通、飞书校园版、PowerSchool 等主流 K12 产品的公告/通知模块,对比差距如下:
|
||||
|
||||
| 维度 | 行业主流实践 | 当前实现 | 差距影响 |
|
||||
|------|------------|---------|---------|
|
||||
| **多受众定向** | 支持多班级/多年级/多角色组合发布(如"高三1班+2班家长") | 仅支持单年级或单班级 | 年级组长需重复发布 N 次 |
|
||||
| **富文本/附件** | 富文本编辑器 + 附件(PDF 通知、图片) | 纯文本 `whitespace-pre-wrap` | 学校正式通知无法排版、无法附带 PDF |
|
||||
| **分类/标签** | 学科、活动、安全、家长信等分类筛选 | 仅按 status 筛选 | 家长在海量公告中找不到关注项 |
|
||||
| **定时发布** | 选择未来时间自动发布 | schema 有 `publishedAt` 但 UI 未消费 | 管理员需手动踩点发布 |
|
||||
| **到期/置顶** | 自动到期 + 多级优先级(紧急/普通) | 仅 pinned 布尔 | 紧急通知与普通通知无差异 |
|
||||
| **已读统计仪表盘** | 管理端列表展示每条公告已读率、未读名单、可一键催读 | `readCount` 字段存在但 UI 未展示 | 管理员无法评估公告触达效果 |
|
||||
| **草稿预览** | 编辑时预览发布后效果 | 无预览 | 发布前无法验证排版 |
|
||||
| **批量操作** | 列表多选 + 批量归档/删除 | 逐条操作 | 学期末清理 50 条公告需 50 次点击 |
|
||||
| **搜索** | 标题/正文全文搜索 | 无搜索 | 历史公告无法检索 |
|
||||
| **Dashboard 集成** | 首页"最新公告"Widget + 未读红点 | 仅家长端有快速入口链接 | 用户必须主动进入公告页 |
|
||||
| **通知点击回跳** | 点击通知直达公告详情并自动已读 | 通知 actionUrl 指向详情页,但详情页无受众校验 | 通知点击可能触发越权 |
|
||||
| **多语言/多角色文案** | 同一公告对家长/学生/教师展示不同侧重点 | 同一文案对所有角色 | 家长看到教师内部用语 |
|
||||
| **无障碍** | 列表语义化 `<ul>`/`<li>`、键盘可达 | `<div>` + `<Link>` 包裹按钮 | 屏幕阅读器用户导航困难 |
|
||||
| **错误恢复** | 失败自动重试 + 离线草稿 | 失败仅 Toast 提示 | 网络波动时内容丢失 |
|
||||
|
||||
**核心差距**:当前实现停留在"CRUD + 状态机"的最小可用形态,缺少 K12 公告模块的"触达-反馈-统计"闭环。其中"已读统计不可见"和"无富文本/附件"是 K12 学校最痛的两个缺口。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(必须立即修复 · 安全与数据正确性)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P0-1 | 详情页越权读取 | 新增 `getAnnouncementByIdForUser(id, userId, dataScope)` data-access 函数,结合 `status="published"` 与受众过滤;路由层调用此函数,未命中返回 `notFound()` |
|
||||
| P0-2 | `resolveAudience` 多孩子/多年级截断 | 将 `audience` 参数升级为 `{ gradeIds: string[]; classIds: string[] }`;`getAnnouncements` 用 `inArray` 查询;`resolveAudience` 返回完整数组而非首个 |
|
||||
| P0-3 | 置顶/已读 Action 缺资源级校验 | `toggleAnnouncementPinAction` 校验公告存在;`markAnnouncementAsReadAction` 调用新增的 `getAnnouncementByIdForUser` 校验可见性后再写入 |
|
||||
|
||||
### P1(高优先级 · 架构与可维护性)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P1-1 | Actions 返回英文硬编码 | 引入 `getTranslations("announcements")`,所有 `ActionState.message` 改用 i18n 键;新增 `messageKey` 字段或直接返回本地化字符串 |
|
||||
| P1-2 | 死代码:管理端详情分支 / `getAnnouncementReadStatusAction` | 新增 `/admin/announcements/[id]/view` 详情页消费 `AnnouncementDetail canManage=true`;列表组件调用 `getAnnouncementReadStatusAction` 展示已读/未读角标;或删除死分支 |
|
||||
| P1-3 | 组件直接 import actions | 新建 `announcements-service-context.tsx`,定义 `AnnouncementsService` 接口(含 `togglePin` / `publish` / `archive` / `delete` / `markRead` / `create` / `update` 方法签名),用 Provider 注入默认实现;组件 `useContext` 消费 |
|
||||
| P1-4 | `AnnouncementForm` 硬编码路由 | 新增 `onSuccess?` / `onCancel?` 回调 prop,回调优先于 `router.push`;默认 `successHref` prop 兜底 |
|
||||
| P1-5 | `resolveTargetUserIds` 放 actions.ts | 下沉到 `data-access.ts` 的 `resolveAnnouncementTargetUserIds(announcement)` 纯函数;actions.ts 仅做编排 |
|
||||
| P1-6 | 非原子 toggle / markRead | `toggleAnnouncementPin` 改为 `UPDATE ... SET is_pinned = NOT is_pinned`;`markAnnouncementAsRead` 改为 `INSERT ... ON DUPLICATE KEY UPDATE id=id`(Drizzle 的 `onDuplicateKeyUpdate`) |
|
||||
| P1-7 | `handleActionError` 吞错误 | 新增 `console.error` + `trackEvent("announcement.action_error")`;message 走 i18n;不向客户端返回原始 `e.message` |
|
||||
| P1-8 | 表单与 Dialog 行为冲突 | 表单通过 `onSuccess` 回调关闭 Dialog;移除表单内的 `router.push` |
|
||||
|
||||
### P2(中优先级 · 体验与工程化)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P2-1 | a11y:按钮嵌套在 Link 内 | 重构 `AnnouncementCard`:卡片本身为 `<Link>`,置顶按钮用绝对定位 + `z-index` 独立于链接,或改用 `<article>` + 独立链接 + 独立按钮的语义结构 |
|
||||
| P2-2 | `mapRow` 类型脱钩 | 改用 `typeof announcements.$inferSelect` 推导;移除手写内联类型 |
|
||||
| P2-3 | i18n 死键 / 缺键 | 删除未使用的 9 个 `messages.*` 死键(或随 P1-1 启用);补 `description.detail` / `description.create` |
|
||||
| P2-4 | `detailHrefBuilder` 死 prop | 删除该 prop,仅保留 `detailHrefPrefix` |
|
||||
| P2-5 | 重复骨架屏 | 抽取 `AnnouncementListSkeleton` 共享组件到 `components/` |
|
||||
| P2-6 | ✅ 已实施 | 无分页 UI → 新增 `AnnouncementPagination` 组件;`getUserAnnouncementsPageData` 返回 `{ items, total, page, pageSize }` |
|
||||
| P2-7 | ✅ 已实施 | 无测试 → 新增 `schema.test.ts`(18 测试,`refineAudience` 矩阵)、`is-announcement-visible.test.ts`(15 测试,纯函数含多孩子场景)、`announcement-card.test.tsx`(16 测试,交互 + a11y) |
|
||||
|
||||
### 中长期(P3 · 功能演进,对应行业差距)
|
||||
|
||||
| # | 方向 | 说明 |
|
||||
|---|------|------|
|
||||
| P3-1 | 富文本 + 附件 | 接入 `files` 模块(已支持 `targetType="announcement"`);引入轻量富文本编辑器(如 Tiptap) |
|
||||
| P3-2 | 分类/标签 | 新增 `announcement_tags` 表 + 列表筛选;预置 K12 分类(学科/活动/安全/家长信) |
|
||||
| P3-3 | 定时发布 | 表单增加 `publishedAt` 日期选择器;新增 cron 校验到点自动 `status="published"` |
|
||||
| P3-4 | 已读统计仪表盘 | 管理端列表展示已读率柱状图;详情页展示未读名单 + 一键催读(触发 `sendBatchNotifications`) |
|
||||
| P3-5 | 批量操作 | 列表多选 + 批量归档/删除 Action |
|
||||
| P3-6 | 全文搜索 | 接入 `app/api/search` 已有的全局搜索(当前已支持 announcement 类型) |
|
||||
| P3-7 | Dashboard 集成 | 新增 `AnnouncementsWidget`(最新 3 条 + 未读红点),挂载到各角色 Dashboard |
|
||||
| P3-8 | 草稿预览 | 表单"预览"按钮展开只读视图 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现 `004_architecture_impact_map.md` §2.16 与 `005_architecture_data.json#announcements` 存在以下遗漏,需在实施后同步更新:
|
||||
|
||||
1. **新增节点**:
|
||||
- `data-access.resolveAnnouncementTargetUserIds`(P1-5 下沉的纯函数)
|
||||
- `data-access.getAnnouncementByIdForUser`(P0-1 新增的受众过滤读取)
|
||||
- `AnnouncementsServiceContext`(P1-3 新增的依赖注入 Provider)
|
||||
- `AnnouncementListSkeleton`(P2-5 抽取的共享骨架屏)
|
||||
- `AnnouncementPagination`(P2-6 新增的分页组件)
|
||||
|
||||
2. **删除节点**:
|
||||
- `actions.getAnnouncementReadStatusAction`(若 P1-2 选择删除而非启用)
|
||||
- `AnnouncementList.detailHrefBuilder` prop(P2-4 删除)
|
||||
|
||||
3. **修改节点**:
|
||||
- `GetAnnouncementsParams.audience` 类型从 `{ gradeId?; classId? }` 改为 `{ gradeIds: string[]; classIds: string[] }`
|
||||
- `AnnouncementDetail` 的 `canManage` 分支启用记录(新增管理端详情页后)
|
||||
- `actions.ts` 行数变化(下沉 `resolveTargetUserIds` 后减少)
|
||||
- `data-access.ts` 行数变化(新增函数后增加)
|
||||
|
||||
4. **新增依赖关系**:
|
||||
- `announcements → files`(P3-1 附件集成后)
|
||||
- `announcements → dashboard`(P3-7 Widget 集成后)
|
||||
|
||||
5. **已知问题清单更新**:
|
||||
- 新增"P0-1 详情页越权"(修复后标记 ✅)
|
||||
- 新增"P0-2 多孩子截断"(修复后标记 ✅)
|
||||
- 新增"P0-3 资源级校验缺失"(修复后标记 ✅)
|
||||
- 新增"P1-1 Actions i18n"(修复后标记 ✅)
|
||||
|
||||
---
|
||||
|
||||
## 附:实施清单(与上述优先级一一对应)
|
||||
|
||||
实施将按 P0 → P1 → P2 → P3 顺序推进,P3 为中长期演进,本次实施聚焦 P0/P1/P2,P3 中富文本/附件/Dashboard 集成将择期推进。每完成一项同步更新架构图与运行 `npm run lint` + `npx tsc --noEmit` 验证。
|
||||
@@ -0,0 +1,251 @@
|
||||
# 公告和消息模块审计报告 V3
|
||||
|
||||
> 审查日期:2026-06-22
|
||||
> 审查范围:V2 改进后的 `src/modules/announcements/**`、`src/modules/messaging/**`、`src/modules/notifications/**`、对应路由层、i18n 翻译文件
|
||||
> 前置文档:`announcements-messages-audit-report.md`(V1)、`announcements-messages-audit-report-v2.md`(V2)
|
||||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.13 / §2.14 / §2.16
|
||||
|
||||
---
|
||||
|
||||
## 一、V2 完成情况复核
|
||||
|
||||
| V2 编号 | 标题 | 状态 |
|
||||
|---------|------|------|
|
||||
| V2-P0-1 | 通知 i18n 命名空间独立 | ✅ 已完成 |
|
||||
| V2-P0-2 | 通知标题 i18n 化 | ✅ 已完成 |
|
||||
| V2-P1-1 | AnnouncementList 过滤模式统一 | ✅ 已完成 |
|
||||
| V2-P1-2 | MessageList 过滤冗余移除 | ✅ 已完成 |
|
||||
| V2-P1-3 | 消息详情页编排下沉 | ✅ 已完成 |
|
||||
| V2-P1-4 | 表单服务端校验错误展示 | ✅ 已完成 |
|
||||
| V2-P2-1 | 轮询间隔常量化 | ✅ 已完成 |
|
||||
| V2-P2-2 | 架构图同步 | ✅ 已完成 |
|
||||
| V2-P2-13b | 通知优先级和归档 | ✅ 已完成 |
|
||||
| V2-P2-13c | 通知分类筛选 + 桌面推送 | ✅ 已完成 |
|
||||
| V2-P2-13d | 公告置顶 + 已读回执 | ✅ 已完成 |
|
||||
|
||||
V2 共 11 项已全部实施。
|
||||
|
||||
---
|
||||
|
||||
## 二、V3 新发现问题
|
||||
|
||||
### 2.1 `saveMessageDraftAction` 中 `as` 断言违规(P0)
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [messaging/actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L279-283 | `formData.get("draftId") as string \| null` 等 5 处断言 | "禁止 `as` 断言(除非从 `unknown` 转换)" |
|
||||
|
||||
**问题分析**:
|
||||
- `FormData.get()` 返回类型为 `string | File | null`
|
||||
- 代码直接断言为 `string | null`,若字段为 File 类型会导致运行时错误
|
||||
- 应使用类型守卫或 Zod 校验进行安全转换
|
||||
|
||||
**后果**:类型不安全,上传场景下可能运行时崩溃;违反 TypeScript 严格模式规则。
|
||||
|
||||
### 2.2 公告列表页 `resolveAudience` 业务逻辑未下沉(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [announcements/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/page.tsx) L29-78 | `resolveAudience` 函数 50 行业务逻辑在路由层 | "app/ 只能调用 modules 的 Server Actions 和 data-access,不直接访问数据库" + "页面层编排应下沉" |
|
||||
|
||||
**问题分析**:
|
||||
- `resolveAudience` 包含根据 dataScope 解析受众的复杂业务逻辑(5 种 dataScope 分支)
|
||||
- 直接调用 `classes` 模块的 3 个 data-access 函数(`getStudentActiveClassId`、`getStudentActiveGradeId`、`getClassGradeId`)
|
||||
- V2-P1-5 已为管理端和编辑页创建编排函数,但用户端列表页遗漏
|
||||
|
||||
**后果**:路由层承担业务逻辑,违反三层架构;逻辑无法复用;测试困难。
|
||||
|
||||
### 2.3 通知偏好 Action 放置位置错误(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [messaging/actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L334-396 | `getNotificationPreferencesAction` / `updateNotificationPreferencesAction` 在 messaging 模块 | "模块标准结构" — 通知偏好属于 notifications 模块 |
|
||||
|
||||
**问题分析**:
|
||||
- V1-P0-4 已将通知偏好 data-access 迁移到 `notifications/preferences.ts`
|
||||
- 但对应的 Server Action 仍留在 `messaging/actions.ts`,使用 `MESSAGE_READ` 权限
|
||||
- 消费方(settings 模块)通过 `SettingsService` 接口注入,但 Action 实现仍在 messaging
|
||||
|
||||
**后果**:模块边界混乱;messaging 模块承担了不属于它的通知偏好职责;权限语义不正确。
|
||||
|
||||
### 2.4 `sendBatchNotifications` 重复日志(P1)
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [notifications/dispatcher.ts](file:///e:/Desktop/CICD/src/modules/notifications/dispatcher.ts) L128 + L149 | `sendNotification` 内部调用 `logNotificationSendBatch`,`sendBatchNotifications` 又调用一次 | "代码质量规则" — 重复逻辑 |
|
||||
|
||||
**问题分析**:
|
||||
- L128:`sendNotification` 末尾调用 `logNotificationSendBatch(results, ...)`
|
||||
- L149:`sendBatchNotifications` 末尾再次调用 `logNotificationSendBatch(flatResults)`
|
||||
- 批量发送时每条通知的日志被记录两次
|
||||
|
||||
**后果**:日志数据重复,影响监控准确性;浪费存储。
|
||||
|
||||
### 2.5 腾讯云短信 `SmsSdkAppId` 配置混淆(P1)
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [sms-channel.ts](file:///e:/Desktop/CICD/src/modules/notifications/channels/sms-channel.ts) L201, L203 | `SmsSdkAppId: this.config.templateCode` 和 `TemplateId: this.config.templateCode` | "类型安全" — 配置语义错误 |
|
||||
|
||||
**问题分析**:
|
||||
- 腾讯云 SMS API 要求 `SmsSdkAppId`(应用 ID)和 `TemplateId`(模板 ID)是两个不同值
|
||||
- 代码中两者都使用 `this.config.templateCode`(来自 `SMS_TEMPLATE_CODE` 环境变量)
|
||||
- `getSmsConfig()` 缺少 `smsSdkAppId` 字段
|
||||
|
||||
**后果**:腾讯云短信发送必然失败(SmsSdkAppId 不等于模板 ID);生产环境无法使用腾讯云短信。
|
||||
|
||||
### 2.6 内联类型导入(P1)
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [messaging/actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L260 | `Promise<ActionState<import("./types").MessageDraft[]>>` | "TypeScript 规则 — 仅用于类型的导入必须使用 import type" |
|
||||
| [messaging/data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L285 | `import("@/modules/notifications/types").Notification[]` | 同上 |
|
||||
|
||||
**后果**:可读性差,不符合 TypeScript 规范。
|
||||
|
||||
### 2.7 组件层 `as` 断言轻微违规(P2)
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L66 | `setTab(v as Tab)` | "禁止 as 断言" |
|
||||
| [notification-list.tsx](file:///e:/Desktop/CICD/src/modules/notifications/components/notification-list.tsx) L106 | `Object.keys(TYPE_ICON) as NotificationType[]` | 同上 |
|
||||
|
||||
**后果**:类型不安全;应使用类型守卫。
|
||||
|
||||
### 2.8 `logNotificationSend` 使用 console(P2)
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [notifications/data-access.ts](file:///e:/Desktop/CICD/src/modules/notifications/data-access.ts) L210, L230 | `console.info` / `console.error` | "监控" — 应使用统一日志服务 |
|
||||
|
||||
**后果**:生产环境日志分散,无法集中监控。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
结合 K12 教育系统特点,对比钉钉教育、企业微信教育版、智学网、班级小管家等产品:
|
||||
|
||||
| 功能 | 我们 | 行业标杆 | 影响 |
|
||||
|------|------|---------|------|
|
||||
| 消息已读回执 | ❌ 无 | ✅ 钉钉/企微支持 | 教师无法确认家长是否已读重要通知 |
|
||||
| 消息模板 | ❌ 无 | ✅ 智学网预设模板 | 教师每次手写消息效率低 |
|
||||
| 公告定时发布 | ❌ 仅即时发布 | ✅ 钉钉支持定时 | 无法提前编排非工作时间发布 |
|
||||
| 公告附件 | ❌ 无 | ✅ 企微/钉钉支持 | 无法附带 PDF/图片等材料 |
|
||||
| 公告分类标签 | ❌ 无 | ✅ 智学网分类 | 公告列表无法按类型快速筛选 |
|
||||
| 消息群发多班级 | ❌ 单收件人 | ✅ 钉钉群发 | 教师需逐个发送,效率低 |
|
||||
| 公告评论/确认 | ❌ 仅已读回执 | ✅ 钉钉确认回执 | 无法收集家长确认反馈 |
|
||||
| 消息搜索 | ✅ 有(按主题) | ✅ 按内容搜索 | 基本满足 |
|
||||
| 通知优先级 | ✅ 有(V2-P2-13b) | ✅ | 已对齐 |
|
||||
| 通知归档 | ✅ 有(V2-P2-13b) | ✅ | 已对齐 |
|
||||
| 桌面推送 | ✅ 有(V2-P2-13c) | ✅ | 已对齐 |
|
||||
| SSE 实时推送 | ✅ 有(V2-P3) | ✅ | 已对齐 |
|
||||
|
||||
**主要差距**:消息已读回执、公告定时发布、公告附件为高价值缺失功能,直接影响家校沟通效率。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响类型安全与架构合规)
|
||||
|
||||
1. **修复 `saveMessageDraftAction` 的 `as` 断言**:使用类型守卫替代 `formData.get() as string | null`,安全处理 File 类型。
|
||||
2. **公告列表页 `resolveAudience` 下沉**:将受众解析逻辑迁移到 `announcements/data-access.ts`,新增 `getUserAnnouncementsPageData` 编排函数。
|
||||
|
||||
### P1(重要,影响模块边界与功能正确性)
|
||||
|
||||
3. **通知偏好 Action 迁移**:将 `getNotificationPreferencesAction` / `updateNotificationPreferencesAction` 从 `messaging/actions.ts` 迁移到 `notifications/actions.ts`,使用通知相关权限。
|
||||
4. **修复 `sendBatchNotifications` 重复日志**:移除 `sendBatchNotifications` 中的重复 `logNotificationSendBatch` 调用。
|
||||
5. **修复腾讯云短信 `SmsSdkAppId` 配置**:新增 `SMS_SDK_APP_ID` 环境变量和 `smsSdkAppId` 配置字段。
|
||||
6. **消除内联类型导入**:改为顶部 `import type`。
|
||||
|
||||
### P2(优化,提升代码质量)
|
||||
|
||||
7. **组件 `as` 断言清理**:使用类型守卫替代。
|
||||
8. **日志服务统一**:`logNotificationSend` 改用统一日志接口(预留接口,当前保持 console 但加注释标记为 TODO)。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现以下架构图需更新:
|
||||
|
||||
1. **§2.13 messaging**:
|
||||
- 移除 `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(迁移至 notifications)
|
||||
- 更新 `actions.ts` 行数(减少约 60 行)
|
||||
- 新增 `saveMessageDraftAction` 类型安全改进说明
|
||||
|
||||
2. **§2.14 notifications**:
|
||||
- 新增 `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`
|
||||
- 更新 `actions.ts` 行数(增加约 60 行)
|
||||
- 修复 `sendBatchNotifications` 重复日志说明
|
||||
- 新增 `smsSdkAppId` 配置字段说明
|
||||
|
||||
3. **§2.16 announcements**:
|
||||
- 新增 `getUserAnnouncementsPageData` 编排函数
|
||||
- 更新 `data-access.ts` 行数
|
||||
- 更新用户端列表页说明(使用编排函数)
|
||||
|
||||
4. **附录 A 依赖矩阵**:
|
||||
- messaging 对 notifications 的依赖减少(不再包含通知偏好 Action)
|
||||
- announcements 对 classes 的依赖改为通过编排函数间接调用
|
||||
|
||||
---
|
||||
|
||||
## 六、实施记录
|
||||
|
||||
以下为 V3 审计报告的实施记录,所有修复均已完成并通过 `npx tsc --noEmit` 和 `npm run lint` 验证。
|
||||
|
||||
### V3-P0-1:修复 `saveMessageDraftAction` 的 `as` 断言
|
||||
|
||||
**文件**:`src/modules/messaging/actions.ts`
|
||||
|
||||
**变更**:将 L279-283 的 5 处 `formData.get("xxx") as string | null` 替换为类型守卫函数 `getStringFromFormData`,安全处理 `File` 类型。
|
||||
|
||||
### V3-P0-2:公告列表页 `resolveAudience` 下沉
|
||||
|
||||
**文件**:
|
||||
- `src/modules/announcements/data-access.ts`:新增 `getUserAnnouncementsPageData` 编排函数
|
||||
- `src/app/(dashboard)/announcements/page.tsx`:移除 `resolveAudience`,改用编排函数
|
||||
|
||||
### V3-P1-3:通知偏好 Action 迁移
|
||||
|
||||
**文件**:
|
||||
- `src/modules/notifications/actions.ts`:新增 `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`
|
||||
- `src/modules/messaging/actions.ts`:移除上述 2 个 Action 及相关 import
|
||||
|
||||
### V3-P1-4:修复 `sendBatchNotifications` 重复日志
|
||||
|
||||
**文件**:`src/modules/notifications/dispatcher.ts`
|
||||
|
||||
**变更**:移除 `sendBatchNotifications` 中的重复 `logNotificationSendBatch` 调用(L149)。
|
||||
|
||||
### V3-P1-5:修复腾讯云短信 `SmsSdkAppId` 配置
|
||||
|
||||
**文件**:`src/modules/notifications/channels/sms-channel.ts`
|
||||
|
||||
**变更**:`getSmsConfig()` 新增 `smsSdkAppId` 字段(来自 `SMS_SDK_APP_ID` 环境变量),`SmsSdkAppId` 使用独立配置值。
|
||||
|
||||
### V3-P1-6:消除内联类型导入
|
||||
|
||||
**文件**:
|
||||
- `src/modules/messaging/actions.ts`:L260 内联 import 改为顶部 `import type`
|
||||
- `src/modules/messaging/data-access.ts`:L285 内联 import 改为顶部 `import type`
|
||||
|
||||
### V3-P2-7:组件 `as` 断言清理
|
||||
|
||||
**文件**:
|
||||
- `src/modules/messaging/components/message-list.tsx`:L66 `setTab(v as Tab)` 改为类型守卫
|
||||
- `src/modules/notifications/components/notification-list.tsx`:L106 `Object.keys(TYPE_ICON) as NotificationType[]` 改为类型守卫
|
||||
|
||||
### V3-P2-8:日志服务统一(预留)
|
||||
|
||||
**文件**:`src/modules/notifications/data-access.ts`
|
||||
|
||||
**变更**:`console.info` / `console.error` 添加 TODO 注释标记,预留统一日志服务接入点。
|
||||
|
||||
### 架构图同步
|
||||
|
||||
**文件**:
|
||||
- `docs/architecture/004_architecture_impact_map.md`:§2.13 / §2.14 / §2.16 同步更新
|
||||
- `docs/architecture/005_architecture_data.json`:对应节点同步更新
|
||||
443
docs/architecture/audit/attendance-audit-report.md
Normal file
443
docs/architecture/audit/attendance-audit-report.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# 考勤(Attendance)模块审计报告
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:`src/modules/attendance/**`、`src/app/(dashboard)/{admin,teacher,student,parent}/attendance/**`、跨模块依赖 `src/modules/parent/components/parent-attendance-*.tsx` 及 `child-detail-panel.tsx`、i18n `src/shared/i18n/messages/{en,zh-CN}/attendance.json`
|
||||
> 参照规则:`docs/architecture/004_architecture_impact_map.md`(第 2.10 节)、`docs/architecture/005_architecture_data.json`(L14681 起)、`.trae/rules/project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts) | 258 | 5 个写 Action(含权限校验、Zod 校验、归属校验) |
|
||||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts) | 340 | 考勤记录 CRUD + 规则 upsert + 总览统计 + recorder 解析 |
|
||||
| 数据访问 | [data-access-stats.ts](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts) | 206 | 学生/班级考勤汇总(纯函数 `computeStats` + SQL 聚合) |
|
||||
| Schema | [schema.ts](file:///e:/Desktop/CICD/src/modules/attendance/schema.ts) | 43 | Zod 校验(5 个 schema) |
|
||||
| Types | [types.ts](file:///e:/Desktop/CICD/src/modules/attendance/types.ts) | 103 | 类型定义 |
|
||||
| Constants | [constants.ts](file:///e:/Desktop/CICD/src/modules/attendance/constants.ts) | 64 | 状态选项/快捷键/颜色映射 |
|
||||
| Export | [export.ts](file:///e:/Desktop/CICD/src/modules/attendance/export.ts) | 90 | Excel 导出 |
|
||||
| 组件 | [components/attendance-page-layout.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-page-layout.tsx) | 38 | admin/teacher 共用布局插槽 |
|
||||
| 组件 | [components/attendance-sheet.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx) | 418 | 批量点名表单(快捷键、AlertDialog 确认) |
|
||||
| 组件 | [components/attendance-record-list.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-record-list.tsx) | 142 | 记录列表 + 删除对话框 |
|
||||
| 组件 | [components/attendance-filters.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-filters.tsx) | 94 | URL 同步筛选器 |
|
||||
| 组件 | [components/attendance-stats-card.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-card.tsx) | 82 | 单卡片 8 指标 |
|
||||
| 组件 | [components/attendance-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-cards.tsx) | 83 | admin 总览 6 卡片网格 |
|
||||
| 组件 | [components/attendance-stats-class-selector.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-class-selector.tsx) | 27 | 班级筛选 ChipNav |
|
||||
| 组件 | [components/attendance-rules-form.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-rules-form.tsx) | 155 | 规则配置表单 |
|
||||
| 组件 | [components/student-attendance-view.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/student-attendance-view.tsx) | 111 | 学生/家长视图 |
|
||||
| 页面 | [admin/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx) | 91 | 管理员总览(RSC) |
|
||||
| 页面 | [teacher/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx) | 116 | 教师记录列表(RSC) |
|
||||
| 页面 | [teacher/attendance/sheet/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/sheet/page.tsx) | 44 | 教师点名页(RSC) |
|
||||
| 页面 | [teacher/attendance/stats/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/stats/page.tsx) | 85 | 教师班级统计(RSC) |
|
||||
| 页面 | [student/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/attendance/page.tsx) | 40 | 学生汇总(RSC) |
|
||||
| 页面 | [parent/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/attendance/page.tsx) | 66 | 家长多子女聚合(RSC) |
|
||||
| 错误边界 | 4 个 `error.tsx`(admin/teacher/student/parent) | ~96 | **重复严重** |
|
||||
| 跨模块 | [parent/components/parent-attendance-calendar.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx) | 220 | 家长月历视图 |
|
||||
| 跨模块 | [parent/components/parent-attendance-warning.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-warning.tsx) | 110 | 异常预警横幅 |
|
||||
| 跨模块 | [parent/components/parent-attendance-rate-card.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-rate-card.tsx) | 114 | 出勤率汇总卡片 |
|
||||
| 跨模块 | [parent/components/child-detail-panel.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/child-detail-panel.tsx) | 190 | 子女详情面板(含考勤 Tab) |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
└─ getAttendanceRecords / getStudentAttendanceSummary / getClassAttendanceStats (data-access)
|
||||
└─ db (drizzle) → attendanceRecords / attendanceRules 表
|
||||
└─ ⚠ 直接 JOIN users / classes 表(跨模块表查询)
|
||||
└─ 调用 classes/data-access.getClassActiveStudentsWithInfo(合规)
|
||||
└─ <AttendanceSheet> (client) → batchRecordAttendanceAction
|
||||
└─ <AttendanceRecordList> (client) → deleteAttendanceAction
|
||||
└─ <AttendanceRulesForm> (client) → saveAttendanceRulesAction
|
||||
```
|
||||
|
||||
### 1.3 架构图完整性评估
|
||||
|
||||
架构影响地图(004 第 2.10 节)与 JSON(L14681 起)**整体覆盖** attendance 模块,但存在 **6 处信息过时/不准确**(详见第五节),需同步更新。模块导出、权限点、依赖关系、路由已记录。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 三层架构合规性
|
||||
|
||||
#### 问题 2.1.1:data-access 层跨模块直接 JOIN 外部表【P0】
|
||||
- **位置**:[data-access.ts:118-119](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L118-L119)、[data-access.ts:77-81](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L77-L81)、[data-access-stats.ts:87-91](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L87-L91)、[data-access-stats.ts:161-165](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L161-L165)、[data-access-stats.ts:177-178](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L177-L178)
|
||||
- **描述**:`getAttendanceRecords` 直接 `leftJoin(users)`、`leftJoin(classes)`;`resolveRecorderNames` 直接 `select from users`;`getStudentAttendanceSummary`/`getClassAttendanceStats` 直接查询 `users`/`classes` 表。
|
||||
- **违反规则**:项目规则「`modules/` 之间通过对方 data-access 通信,**不直接查询对方 DB 表**」。
|
||||
- **原因**:为减少查询往返,在 attendance data-access 内联 JOIN 获取 studentName/className/recorderName。
|
||||
- **后果**:users/classes 模块 schema 变更(如 `users.name` 重命名)会直接破坏 attendance 查询;模块边界失效,无法独立演进。
|
||||
|
||||
#### 问题 2.1.2:parent 模块直接 import attendance 组件【P1】
|
||||
- **位置**:[parent/attendance/page.tsx:4](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/attendance/page.tsx#L4)
|
||||
- **描述**:`parent/attendance/page.tsx` 直接 `import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"`,跨模块 UI 组件依赖。
|
||||
- **违反规则**:项目规则「该模块必须作为独立功能单元,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access」。
|
||||
- **原因**:parent 复用 student 视图组件以减少重复。
|
||||
- **后果**:parent 模块与 attendance 模块 UI 强耦合,attendance 调整 StudentAttendanceView 会影响 parent 页面。
|
||||
|
||||
#### 问题 2.1.3:parent-attendance-calendar 直接依赖 attendance/constants【P1】
|
||||
- **位置**:[parent-attendance-calendar.tsx:9-12](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx#L9-L12)
|
||||
- **描述**:直接 import `ATTENDANCE_STATUS_DOT_COLORS`、`ATTENDANCE_STATUS_LABEL_KEYS`。
|
||||
- **违反规则**:同上「完全解耦」原则。
|
||||
- **原因**:parent 类型已解耦(`parent/types.ts` 自声明类型),但常量仍直接依赖。
|
||||
- **后果**:attendance 常量变更影响 parent 月历渲染。
|
||||
|
||||
#### 问题 2.1.4:架构图信息过时【P2】
|
||||
- **位置**:架构图 004 第 1152、1154、1159、1175、1176、1195 行
|
||||
- **描述**:6 处描述与实际代码不符(详见第五节)。
|
||||
- **违反规则**:项目规则「改码必同步图」。
|
||||
- **后果**:架构图可信度下降,误导后续开发。
|
||||
|
||||
### 2.2 权限校验
|
||||
|
||||
#### 问题 2.2.1:teacher 子页面缺失权限校验【P0】
|
||||
- **位置**:[teacher/attendance/sheet/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/sheet/page.tsx)、[teacher/attendance/stats/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/stats/page.tsx)
|
||||
- **描述**:两个页面**无任何权限校验**,未调用 `requirePermission(Permissions.ATTENDANCE_READ)`,也未通过 `getAuthContext().dataScope` 过滤。
|
||||
- **违反规则**:项目规则「所有 Server Action 必须调用 `requirePermission()` 进行权限校验」+ 项目记忆「Parent routes must include permission checks with both parentId and studentId」。
|
||||
- **原因**:RSC 页面非 Server Action,开发者认为 data-access 内的 `buildScopeFilter` 会兜底。
|
||||
- **后果**:sheet 页调用 `getTeacherClasses()` **未传入 scope**([teacher/attendance/sheet/page.tsx:9](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/sheet/page.tsx#L9)),任何已登录用户访问 URL 即可获取教师班级学生列表;stats 页同理。**存在数据越权风险**。
|
||||
|
||||
#### 问题 2.2.2:teacher/student/parent 主页面权限校验不一致【P1】
|
||||
- **位置**:[teacher/attendance/page.tsx:39](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx#L39)、[student/attendance/page.tsx:11](file:///e:/Desktop/CICD/src/app/(dashboard)/student/attendance/page.tsx#L11)
|
||||
- **描述**:仅 `getAuthContext()`,未 `requirePermission(ATTENDANCE_READ)`;而 [admin/attendance/page.tsx:30](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx#L30) 已建立该惯例。
|
||||
- **违反规则**:权限校验应统一。
|
||||
- **后果**:权限点缺失,无法通过权限矩阵精确控制 teacher/student 是否可访问考勤页。
|
||||
|
||||
#### 问题 2.2.3:前端删除按钮无权限点控制【P2】
|
||||
- **位置**:[attendance-record-list.tsx:106-114](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-record-list.tsx#L106-L114)
|
||||
- **描述**:删除按钮对所有能看到列表的用户可见,未使用 `usePermission().hasPermission(Permissions.ATTENDANCE_MANAGE)` 控制显隐。
|
||||
- **违反规则**:项目规则「前端权限判断统一使用 `usePermission().hasPermission()`」。
|
||||
- **后果**:无权用户看到删除按钮,点击后才被 Server Action 拒绝,体验差。
|
||||
|
||||
### 2.3 i18n 国际化
|
||||
|
||||
#### 问题 2.3.1:safeParseDate 中文 fieldName 硬编码【P0】
|
||||
- **位置**:[data-access.ts:100-102](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L100-L102)、[data-access.ts:167](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L167)、[data-access.ts:185](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L185)、[data-access.ts:308](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L308)、[data-access-stats.ts:95-96](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L95-L96)、[data-access-stats.ts:169-170](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L169-L170)
|
||||
- **描述**:`safeParseDate(value, "日期")`、`safeParseDate(value, "开始日期")` 等 10 处中文 fieldName,经 `handleActionError` 返回客户端为用户可见错误消息。
|
||||
- **违反规则**:项目规则「所有用户可见文本必须适配 i18n」。
|
||||
- **后果**:英文环境下显示中文错误。
|
||||
|
||||
#### 问题 2.3.2:action-utils shared 层中文兜底消息【P1】
|
||||
- **位置**:`shared/lib/action-utils.ts:32,70,74,143`
|
||||
- **描述**:`NotFoundError(\`${resource} 不存在\`)`、`"操作失败,请稍后重试"`、`${fieldName} 格式无效` 等。
|
||||
- **违反规则**:同上。
|
||||
- **后果**:所有调用 shared 层的模块(含 attendance)均受影响。
|
||||
|
||||
#### 问题 2.3.3:Excel 导出英文列头硬编码【P1】
|
||||
- **位置**:[export.ts:83-84](file:///e:/Desktop/CICD/src/modules/attendance/export.ts#L83-L84)
|
||||
- **描述**:`"Metric"`、`"Value"` 硬编码。
|
||||
|
||||
#### 问题 2.3.4:calendar 硬编码 en-US locale【P1】
|
||||
- **位置**:[parent-attendance-calendar.tsx:102](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx#L102)
|
||||
- **描述**:`toLocaleDateString("en-US")`,未使用当前 locale。
|
||||
|
||||
#### 问题 2.3.5:child-detail-panel 英文硬编码【P1】
|
||||
- **位置**:[child-detail-panel.tsx:50-56](file:///e:/Desktop/CICD/src/modules/parent/components/child-detail-panel.tsx#L50-L56)、L111、L137-143、L152、L160-163、L182、L44、L186
|
||||
- **描述**:Tab 标签、区块标题、占位提示、按钮文案共 20+ 处英文硬编码。
|
||||
|
||||
#### 问题 2.3.6:翻译键误用【P0】
|
||||
- **位置**:
|
||||
- [parent/attendance/error.tsx:17](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/attendance/error.tsx#L17)、[teacher/attendance/error.tsx:17](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/error.tsx#L17)、[admin/attendance/error.tsx:17](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/error.tsx#L17):重试按钮使用 `t("actions.save")` 而非 `t("actions.retry")`,显示"保存"。
|
||||
- [attendance-record-list.tsx:127](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-record-list.tsx#L127):删除确认对话框描述使用 `t("errors.unexpected")`("发生未知错误"),应为 `t("sheet.confirmDelete")`。
|
||||
- [attendance-rules-form.tsx:32](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-rules-form.tsx#L32):保存按钮显示 `t("rules.saved")`("考勤规则已保存"),应为 `t("actions.save")`。
|
||||
- [attendance-sheet.tsx:395](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L395):切换班级确认对话框标题误用 `t("sheet.confirmDelete")`,应为 `t("sheet.confirmClassSwitch")`。
|
||||
- **违反规则**:i18n 正确性。
|
||||
- **后果**:用户看到错误/误导文案。
|
||||
|
||||
#### 问题 2.3.7:error.tsx title/description 重复【P2】
|
||||
- **位置**:4 个 error.tsx
|
||||
- **描述**:title 和 description 均用 `t("errors.unexpected")`,完全相同。
|
||||
|
||||
### 2.4 类型安全
|
||||
|
||||
#### 问题 2.4.1:export.ts 无类型守卫的 as 断言【P1】
|
||||
- **位置**:[export.ts:28-34](file:///e:/Desktop/CICD/src/modules/attendance/export.ts#L28-L34)
|
||||
- **描述**:`params.status as "present" | "absent" | ...`,`params.status` 为 `string | undefined`,无类型守卫。
|
||||
- **违反规则**:项目规则「禁止 `as` 断言(除非从 `unknown` 转换)」。
|
||||
- **后果**:非法 status 值绕过类型检查。
|
||||
|
||||
#### 问题 2.4.2:child-detail-panel as 断言【P2】
|
||||
- **位置**:[child-detail-panel.tsx:29](file:///e:/Desktop/CICD/src/modules/parent/components/child-detail-panel.tsx#L29)、L65
|
||||
- **描述**:`(VALID_TABS as string[]).includes(v)`、`v as ChildDetailTab`,已有 `isTab` 守卫但未在 `onValueChange` 使用。
|
||||
|
||||
### 2.5 错误处理与边界
|
||||
|
||||
#### 问题 2.5.1:teacher 子路由缺失 error.tsx【P1】
|
||||
- **位置**:`teacher/attendance/sheet/`、`teacher/attendance/stats/`
|
||||
- **描述**:缺失 error.tsx,运行时错误冒泡到 `teacher/attendance/error.tsx`,错误上下文不准确。
|
||||
- **违反规则**:项目记忆「All student routes must include loading.tsx and error.tsx」。
|
||||
|
||||
#### 问题 2.5.2:student 空状态文案错误【P2】
|
||||
- **位置**:[student/attendance/page.tsx:24-25](file:///e:/Desktop/CICD/src/app/(dashboard)/student/attendance/page.tsx#L24-L25)
|
||||
- **描述**:summary 为 null 时 EmptyState description 用 `t("errors.unexpected")`("发生未知错误"),实际原因可能是无考勤记录。
|
||||
|
||||
### 2.6 组件复用性
|
||||
|
||||
#### 问题 2.6.1:常量在 constants.ts 与 attendance-sheet.tsx 重复定义【P1】
|
||||
- **位置**:[attendance-sheet.tsx:50-83](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L50-L83)
|
||||
- **描述**:`STATUS_OPTIONS`、`STATUS_SHORTCUTS`、`createInitialStatusCounts` 与 constants.ts 重复(部分复用、部分重复的混乱状态)。
|
||||
- **违反规则**:DRY 原则。
|
||||
- **后果**:状态选项变更需同步两处,易遗漏。
|
||||
|
||||
#### 问题 2.6.2:4 个 error.tsx 近乎完全重复【P1】
|
||||
- **位置**:4 个 error.tsx
|
||||
- **描述**:结构完全相同,共约 96 行重复代码。
|
||||
- **违反规则**:项目记忆「Shared components must be extracted when page duplication exceeds 90%」。
|
||||
- **后果**:修改一处需同步四处。
|
||||
|
||||
#### 问题 2.6.3:两个 stats 卡片组件数据结构分裂【P2】
|
||||
- **位置**:[attendance-stats-card.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-card.tsx)、[attendance-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-cards.tsx)
|
||||
- **描述**:同一概念两套数据结构(`AttendanceStats` vs `AttendanceOverviewStats`,`stats.present`/`stats.presentRate` vs `stats.presentCount`/`stats.attendanceRate`)。
|
||||
|
||||
### 2.7 数据注入与解耦
|
||||
|
||||
#### 问题 2.7.1:无接口抽象与 Context 注入【P1】
|
||||
- **描述**:attendance 模块未定义任何 `AttendanceDataService` 接口,无 `AttendanceContext`/`AttendanceProvider`,无角色差异的接口多态实现。
|
||||
- **违反规则**:项目规则「通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务」。
|
||||
- **后果**:角色间无统一契约约束,参数/返回处理可能不一致;无法通过接口 mock 做单测。
|
||||
|
||||
### 2.8 可测试性
|
||||
|
||||
#### 问题 2.8.1:data-access 无接口类型可供 mock【P2】
|
||||
- **描述**:data-access 函数直接导出为具体函数,无 `AttendanceRepository` 接口。
|
||||
- **违反规则**:项目规则「导出清晰的接口类型以便 mock」。
|
||||
|
||||
### 2.9 a11y 可访问性
|
||||
|
||||
#### 问题 2.9.1:全局 keydown 监听可能冲突【P2】
|
||||
- **位置**:[attendance-sheet.tsx:164-186](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L164-L186)
|
||||
- **描述**:keydown 绑定在 window,仅排除 input/textarea,未排除 Select 等可交互组件。
|
||||
|
||||
### 2.10 性能
|
||||
|
||||
#### 问题 2.10.1:getClassAttendanceStats 未用 SQL 聚合【P1】
|
||||
- **位置**:[data-access-stats.ts:172-182](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L172-L182)
|
||||
- **描述**:仍用全量查询 + 内存 `computeStats`,而 `getStudentAttendanceSummary`/`getAttendanceStats` 已改用 SQL 聚合。
|
||||
- **违反规则**:性能最佳实践。
|
||||
- **后果**:大班级统计查询慢。
|
||||
|
||||
#### 问题 2.10.2:teacher 分页基于截断数据计算【P0】
|
||||
- **位置**:[teacher/attendance/page.tsx:46-63](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx#L46-L63)
|
||||
- **描述**:先获取 `result.items`(pageSize=20),再对**仅 20 条**做前端分页计算 totalPages。
|
||||
- **后果**:分页页数错误,用户无法访问第 2 页之后数据。
|
||||
|
||||
#### 问题 2.10.3:parent 组件可降级为 RSC【P2】
|
||||
- **位置**:[parent-attendance-warning.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-warning.tsx)、[parent-attendance-rate-card.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-rate-card.tsx)
|
||||
- **描述**:仅因 `useTranslations` 标记 `"use client"`,可用 `getTranslations` 改为 RSC。
|
||||
|
||||
#### 问题 2.10.4:statusCounts 未 memoize【P2】
|
||||
- **位置**:[attendance-sheet.tsx:151-157](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L151-L157)
|
||||
- **描述**:每次 render 全量 reduce,大班级性能损耗。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
基于 K12 教育系统考勤模块的主流实践(参照 PowerSchool、Infinite Campus、Veracross、校长推荐系统等),当前模块相比优秀实践的差距:
|
||||
|
||||
| 维度 | 优秀实践 | 当前状态 | 差距影响 |
|
||||
|------|---------|---------|---------|
|
||||
| **考勤状态细分** | present/absent/late/early-leave/excused-absent/school-activity + 事假/病假/公假原因 | 仅 present/absent/late/excused 4 态,无原因字段 | 学校无法区分病假/事假,无法生成请假原因统计 |
|
||||
| **实时家长通知** | 学生缺席自动推送家长 App 通知/短信 | 仅被动查看,无主动推送 | 家长无法及时获知子女缺勤,错过干预窗口 |
|
||||
| **出勤率阈值预警** | 自动识别出勤率低于阈值的学生并通知班主任 | 有 parent-attendance-warning 但仅家长端展示,教师端缺失 | 教师无法主动获取需关注学生名单 |
|
||||
| **考勤趋势可视化** | 折线图展示个人/班级出勤率周/月趋势 | 仅数字统计,无趋势图 | 无法直观看出勤率变化趋势 |
|
||||
| **请假申请流程** | 家长在线提交请假申请,教师/管理员审批,自动同步考勤 | 完全缺失 | 请假流程线下化,考勤数据与请假记录脱节 |
|
||||
| **补签/补录** | 学生事后提交补签申请,教师审核修正 | 缺失,仅管理员/教师可手动修改 | 考勤纠错流程繁琐 |
|
||||
| **跨日/跨节次考勤** | 按课节(早读/上午/下午/晚自习)多次点名 | 仅按日单次记录 | 无法精确到节次,缺勤定位不精确 |
|
||||
| **考勤与成绩关联** | 出勤率与学业成绩相关性分析 | 无关联 | 无法识别"低出勤→低成绩"风险学生 |
|
||||
| **班级对比分析** | 同年级班级出勤率横向对比 | 缺失 | 管理员无法横向评估各班考勤管理水平 |
|
||||
| **导出报告多样性** | PDF 周报/月报、Excel 明细、家长签字单 | 仅 Excel 单一导出 | 无法满足不同场景报告需求 |
|
||||
| **移动端适配** | 移动端点名(平板/手机) | 未验证移动端体验 | 教师课堂点名不便携 |
|
||||
| **考勤日历视图** | 学生/家长端月历视图(已有 parent-attendance-calendar) | 已实现且 a11y 良好 | ✅ 已达行业水准 |
|
||||
| **批量操作效率** | 一键全勤、快捷键、批量按学号录入 | 已实现快捷键 + 一键全勤 | ✅ 已达行业水准 |
|
||||
| **空状态/骨架屏** | 每个数据区块 EmptyState + Skeleton | 已基本覆盖 | ✅ 基本达标 |
|
||||
| **a11y 可访问性** | 语义化、ARIA、键盘导航 | 已实现且较完善 | ✅ 基本达标 |
|
||||
|
||||
**核心差距总结**:
|
||||
1. **功能完整性**:缺请假流程、节次考勤、原因分类、趋势可视化、跨班级对比——这些是 K12 学校的刚需。
|
||||
2. **主动通知机制**:被动展示→主动预警的转变。
|
||||
3. **数据联动**:考勤与成绩、请假、通知模块的联动缺失。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响安全/数据正确性,立即修复)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P0-1 | teacher/sheet、teacher/stats 缺权限校验且未传 scope(2.2.1) | 页面级增加 `requirePermission(ATTENDANCE_READ)` + 传入 `dataScope`;data-access 函数强制要求 scope 参数 |
|
||||
| P0-2 | teacher 分页基于截断数据计算(2.10.2) | 分页 totalPages 使用后端返回的 total,勿基于 items 长度计算 |
|
||||
| P0-3 | safeParseDate 中文 fieldName 硬编码(2.3.1) | 改用 i18n key 或错误 code,前端按 code 本地化 |
|
||||
| P0-4 | 翻译键误用:error 重试按钮显示"保存"(2.3.6) | 3 个 error.tsx 改用 `t("actions.retry")` |
|
||||
| P0-5 | data-access 跨模块直 JOIN users/classes 表(2.1.1) | 委托 `users/data-access`、`classes/data-access` 提供姓名查询接口 |
|
||||
|
||||
### P1(重要,影响可维护性/合规性,短期修复)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P1-1 | teacher/student 主页面缺 requirePermission(2.2.2) | 增加 `requirePermission(ATTENDANCE_READ)` |
|
||||
| P1-2 | parent 直接 import attendance 组件(2.1.2) | 通过接口抽象 + Context 注入,或将共享视图下沉为可注入组件 |
|
||||
| P1-3 | parent-attendance-calendar 依赖 attendance/constants(2.1.3) | 常量下沉到 shared 或通过 props 注入 |
|
||||
| P1-4 | action-utils shared 中文兜底(2.3.2) | 返回错误 code 而非中文消息 |
|
||||
| P1-5 | child-detail-panel 英文硬编码(2.3.5) | 提取 i18n 键 |
|
||||
| P1-6 | export.ts 英文列头 + as 断言(2.3.3、2.4.1) | i18n + 类型守卫 |
|
||||
| P1-7 | calendar 硬编码 en-US(2.3.4) | 使用 `useLocale()`/`getLocale()` |
|
||||
| P1-8 | teacher 子路由缺 error.tsx(2.5.1) | 新增 error.tsx |
|
||||
| P1-9 | 常量重复定义(2.6.1) | attendance-sheet 统一使用 constants.ts |
|
||||
| P1-10 | 4 个 error.tsx 重复(2.6.2) | 抽取 shared `ErrorBoundary` 组件 |
|
||||
| P1-11 | getClassAttendanceStats 未用 SQL 聚合(2.10.1) | 改用 SQL GROUP BY 聚合 |
|
||||
| P1-12 | 无接口抽象/Context 注入(2.7.1) | 定义 `AttendanceDataService` 接口 + Provider |
|
||||
|
||||
### P2(优化,提升体验/性能,中期演进)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P2-1 | 删除按钮无前端权限控制(2.2.3) | `usePermission().hasPermission(ATTENDANCE_MANAGE)` |
|
||||
| P2-2 | error.tsx title/description 重复(2.3.7) | 区分 title/description 键 |
|
||||
| P2-3 | student 空状态文案错误(2.5.2) | 改用 `t("list.emptyDescription")` |
|
||||
| P2-4 | child-detail-panel as 断言(2.4.2) | 使用 isTab 守卫 |
|
||||
| P2-5 | stats 卡片组件数据结构分裂(2.6.3) | 统一为单一数据结构 |
|
||||
| P2-6 | data-access 无接口类型(2.8.1) | 导出 `AttendanceRepository` 接口 |
|
||||
| P2-7 | 全局 keydown 冲突(2.9.1) | 限制监听范围 |
|
||||
| P2-8 | parent 组件可降级 RSC(2.10.3) | 改用 getTranslations |
|
||||
| P2-9 | statusCounts 未 memoize(2.10.4) | useMemo |
|
||||
| P2-10 | 架构图同步(2.1.4) | 更新 004/005 文档 |
|
||||
|
||||
### 中长期演进(功能补齐,对齐行业实践)
|
||||
|
||||
| # | 功能 | 方向 |
|
||||
|---|------|------|
|
||||
| L-1 | 考勤状态细分 + 原因字段 | 扩展 schema 增加 reason 字段,新增 early-leave/school-activity 状态 |
|
||||
| L-2 | 实时家长通知 | 接入 notifications 模块,缺勤自动推送 |
|
||||
| L-3 | 出勤率阈值预警(教师端) | 配置驱动阈值,自动生成需关注学生名单 |
|
||||
| L-4 | 考勤趋势可视化 | 折线图组件,周/月趋势 |
|
||||
| L-5 | 在线请假流程 | 新增 leave-requests 子模块,审批流 + 考勤同步 |
|
||||
| L-6 | 节次考勤 | 扩展数据模型支持按节次记录 |
|
||||
| L-7 | 跨班级对比分析 | 同年级班级出勤率横向对比图 |
|
||||
| L-8 | 导出报告多样化 | PDF 周报/月报 + 家长签字单 |
|
||||
| L-9 | 考勤与成绩关联分析 | 跨模块数据联动分析 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图(004 第 2.10 节、005 JSON L14681 起)存在以下不一致,**需要同步更新**:
|
||||
|
||||
| # | 架构图描述 | 实际代码 | 更新动作 |
|
||||
|---|-----------|---------|---------|
|
||||
| 1 | 004 L1159: `getClassStudentsForAttendance` 仍直查 `classEnrollments` | [data-access.ts:226](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L226) 已委托 `classes/data-access.getClassActiveStudentsWithInfo` | 更新 004 描述为"已委托 classes data-access" |
|
||||
| 2 | 004 L1152: 10 个 Actions(含 5 个读 Action) | actions.ts 仅 5 个写 Action | 更新 Actions 计数为 5,读操作标注为直接调 data-access |
|
||||
| 3 | 004 L1154: `getClassAttendanceStats` 改用 SQL 聚合 | [data-access-stats.ts:172-182](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L172-L182) 仍用全量查询 + computeStats | 待本次重构改为 SQL 聚合后同步更新 |
|
||||
| 4 | 004 L1175: `attendance-sheet.tsx` 使用 `window.confirm` | 已改为 `AlertDialog`(L392-415) | 更新为 AlertDialog |
|
||||
| 5 | 004 L1176: 存在 `{} as Record` 断言 | 已改为 `createInitialStatusCounts()` 函数(L75-83) | 更新描述 |
|
||||
| 6 | 004 L1195: `attendance-stats-cards.tsx` 硬编码中文 | 已全部使用 `t()` i18n | 更新为已 i18n |
|
||||
|
||||
**JSON 同步**:005 中 attendance 节点的 exports、dependencies、permissions 需在本次重构后统一更新(新增 `AttendanceDataService` 接口、`AttendanceProvider`、抽取的 shared `ErrorBoundary`、新增 error.tsx 等)。
|
||||
|
||||
---
|
||||
|
||||
## 六、重构方案设计
|
||||
|
||||
### 6.1 完全解耦:接口抽象 + Context 注入
|
||||
|
||||
**设计目标**:attendance 模块作为独立功能单元,parent/student 等消费方通过接口契约消费,不直接 import 业务实现。
|
||||
|
||||
```typescript
|
||||
// src/modules/attendance/services/attendance-data-service.ts
|
||||
// 接口抽象:定义数据契约,可被不同角色实现
|
||||
export interface AttendanceDataService {
|
||||
getStudentSummary(studentId: string, range?: DateRange): Promise<AttendanceStats | null>;
|
||||
getRecentRecords(studentId: string, limit: number): Promise<AttendanceListItem[]>;
|
||||
getRecordsByDate(date: string, classId?: string): Promise<AttendanceListItem[]>;
|
||||
getClassStats(classId: string, range?: DateRange): Promise<AttendanceStats | null>;
|
||||
}
|
||||
|
||||
// src/modules/attendance/services/attendance-context.tsx
|
||||
// React Context 注入
|
||||
const AttendanceServiceContext = createContext<AttendanceDataService | null>(null);
|
||||
|
||||
export function AttendanceProvider({ service, children }: {
|
||||
service: AttendanceDataService;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AttendanceServiceContext.Provider value={service}>
|
||||
{children}
|
||||
</AttendanceServiceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAttendanceService(): AttendanceDataService {
|
||||
const service = useContext(AttendanceServiceContext);
|
||||
if (!service) throw new Error("AttendanceProvider missing");
|
||||
return service;
|
||||
}
|
||||
|
||||
// 角色实现(示例)
|
||||
// src/modules/attendance/services/student-service.ts —— 学生/家长视角实现
|
||||
// src/modules/attendance/services/teacher-service.ts —— 教师视角实现
|
||||
// src/modules/attendance/services/admin-service.ts —— 管理员视角实现
|
||||
```
|
||||
|
||||
**消费方改造**:`parent/attendance/page.tsx` 注入 `StudentAttendanceService` 实现后渲染 `<StudentAttendanceView>`,不再直接 import attendance 组件;或 attendance 导出纯展示组件,由 parent 通过 props 注入数据。
|
||||
|
||||
### 6.2 组合优先:组件组合与 hooks
|
||||
|
||||
- 所有 UI 通过 `children`/slots/render props 组合,禁止 HOC 深层嵌套。
|
||||
- 逻辑复用抽取为 hooks:`useAttendanceSheet`、`useAttendanceStats`、`useAttendanceFilters`。
|
||||
- `AttendancePageLayout` 已采用插槽模式(header/stats/filters/children),推广至所有角色页面。
|
||||
|
||||
### 6.3 国际化就绪
|
||||
|
||||
**翻译文件结构示例**:
|
||||
```json
|
||||
// src/shared/i18n/messages/zh-CN/attendance.json
|
||||
{
|
||||
"title": "考勤管理",
|
||||
"status": { "present": "出勤", "absent": "缺勤", "late": "迟到", "excused": "请假" },
|
||||
"stats": { "total": "总人次", "presentRate": "出勤率", ... },
|
||||
"errors": {
|
||||
"invalidDate": "日期格式无效",
|
||||
"invalidStartDate": "开始日期格式无效",
|
||||
"unexpected": "发生未知错误,请稍后重试",
|
||||
"forbidden": "无权限执行此操作",
|
||||
"notFound": "考勤记录不存在"
|
||||
},
|
||||
"actions": { "save": "保存", "retry": "重试", "delete": "删除", ... }
|
||||
}
|
||||
```
|
||||
|
||||
**shared 层错误改造**:`action-utils.ts` 返回 `{ code: "INVALID_DATE", field: "date" }` 结构化错误,前端按 code 查 i18n key 本地化,消除中文硬编码。
|
||||
|
||||
### 6.4 最大化复用
|
||||
|
||||
- 抽取 shared `ErrorBoundary` 组件(替代 4 个重复 error.tsx)。
|
||||
- 统一 stats 数据结构为单一 `AttendanceStats`。
|
||||
- 常量统一从 `constants.ts` 导出。
|
||||
- `StudentAttendanceView` 改为接收 `AttendanceDataService` 注入,支持 student/parent 复用。
|
||||
|
||||
### 6.5 错误与边界处理
|
||||
|
||||
- 每个独立数据区块用 React Error Boundary 包裹。
|
||||
- 异步数据用 Suspense + 骨架屏。
|
||||
- 明确处理空数据、无权限、网络异常。
|
||||
|
||||
### 6.6 可测试性
|
||||
|
||||
- data-access 导出 `AttendanceRepository` 接口类型供 mock。
|
||||
- 纯逻辑(`computeStats`、`buildWarnings`、`aggregateStats` 等)已分离,保持。
|
||||
|
||||
### 6.7 可扩展性
|
||||
|
||||
- 角色配置驱动:`attendanceRoleConfig[role]` 决定渲染哪些 Widget/子模块。
|
||||
- 新增角色仅修改配置。
|
||||
|
||||
### 6.8 企业级补充
|
||||
|
||||
- **a11y**:保持现有 ARIA/键盘导航水平,优化 keydown 监听范围。
|
||||
- **性能**:RSC 获取初始数据,客户端组件最小化,支持流式渲染。
|
||||
- **安全**:data-access 层结合 `dataScope` 过滤,Server Action 二次校验。
|
||||
- **监控**:预留 `trackEvent` 埋点接口(点名/删除/规则保存等关键操作)。
|
||||
436
docs/architecture/audit/audit-module-audit-report-v2.md
Normal file
436
docs/architecture/audit/audit-module-audit-report-v2.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# 审计模块审计报告 v2
|
||||
|
||||
> 审计范围:`src/modules/audit/**`、`src/app/(dashboard)/admin/audit-logs/**`、`src/app/api/export/route.ts`(审计导出分支)、`src/app/api/cron/audit-cleanup/route.ts`、`src/shared/lib/audit-logger.ts`、`src/shared/lib/change-logger.ts`、`src/shared/i18n/messages/{zh-CN,en}/audit.json`
|
||||
> 审计日期:2026-06-25
|
||||
> 审计依据:`docs/architecture/004_architecture_impact_map.md`(2.15 节)、`docs/architecture/005_architecture_data.json`(audit 节点)、`docs/standards/coding-standards.md`、项目 `project_rules.md`
|
||||
> 前置报告:`docs/architecture/audit/audit-module-audit-report.md`(v1,2026-06-24)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
v1 审计后已完成两轮重构(P0-P2),当前文件分布如下:
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/page.tsx` | 79 | 操作日志列表页(RSC,调 data-access) |
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/login-logs/page.tsx` | 79 | 登录日志列表页 |
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/data-changes/page.tsx` | 83 | 数据变更日志列表页 |
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/overview/page.tsx` | 34 | 审计概览仪表盘(✅ v1 P2-4 新增) |
|
||||
| 路由 | 4 个 `loading.tsx` + 4 个 `error.tsx` | — | 骨架屏 + 错误边界(✅ v1 P0-2 已修复) |
|
||||
| 路由 | `app/api/export/route.ts` | 202 | 统一导出 API(含 audit/login/dataChange 三分支) |
|
||||
| 路由 | `app/api/cron/audit-cleanup/route.ts` | 75 | 定时清理 cron(✅ v1 P2-5 新增) |
|
||||
| actions | `modules/audit/actions.ts` | 195 | 7 个 Server Action(1 查询 + 3 导出 + 3 保留策略) |
|
||||
| data-access | `modules/audit/data-access.ts` | 387 | 12 个查询函数(分页 + 导出 + 选项 + 统计 + 趋势) |
|
||||
| export | `modules/audit/export.ts` | 133 | Excel 列定义 + 行映射 + buildExport(✅ v1 P1-3 已抽取) |
|
||||
| retention | `modules/audit/retention.ts` | 123 | 保留策略配置读写 + 清理 + 纯函数(✅ v1 P2-5 新增) |
|
||||
| types | `modules/audit/types.ts` | 145 | 类型定义 + 状态映射常量 |
|
||||
| services | `modules/audit/services/audit-service.tsx` | 124 | AuditService 接口 + Context + hook(✅ v1 P2-8 新增) |
|
||||
| services | `modules/audit/services/admin-audit-service.ts` | 40 | 管理员默认实现 |
|
||||
| services | `modules/audit/services/mock-audit-service.ts` | 57 | 测试用 Mock 实现 |
|
||||
| hooks | `modules/audit/hooks/use-log-pagination.ts` | 24 | 分页 URL 状态 hook(✅ v1 P1-2 已抽取) |
|
||||
| 组件 | 15 个组件文件 | 19-191 | 表格/筛选器/视图/详情对话框/概览/图表/保留配置/骨架屏/错误边界 |
|
||||
| 测试 | `export.test.ts` / `retention.test.ts` | 191/81 | 纯函数单测(✅ v1 P2-6 已补充) |
|
||||
| i18n | `shared/i18n/messages/{zh-CN,en}/audit.json` | 130 | 完整翻译键(table/filter/empty/export/error/detail/overview/retention) |
|
||||
| shared | `shared/lib/audit-logger.ts` / `change-logger.ts` | — | `logAudit()` / `logDataChange()` 写入日志 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
├─ requirePermission(AUDIT_LOG_READ)
|
||||
├─ getAuditLogs/getLoginLogs/getDataChangeLogs (data-access 直调)
|
||||
└─ <AuditErrorBoundary>
|
||||
<AuditLogView items={...} /> (Client Component)
|
||||
├─ <AuditLogFilters> (nuqs URL 状态)
|
||||
└─ <AuditLogTable> (分页 + 空状态 + 详情对话框)
|
||||
|
||||
overview/page.tsx (RSC)
|
||||
├─ requirePermission(AUDIT_LOG_READ)
|
||||
├─ getAuditOverviewStats / getAuditTrend / getDataChangeActionStats
|
||||
└─ <AuditOverviewView> (RSC)
|
||||
├─ <AuditOverviewStatsBar>
|
||||
├─ <AuditActivityTrendChart>
|
||||
├─ <DataChangeDistributionChart>
|
||||
└─ <AuditRetentionSettings> (Client Component,直调 Server Actions)
|
||||
```
|
||||
|
||||
导出流:`<AuditLogExportButton>` → `fetch /api/export` → `exportAuditLogsAction` → `getAuditLogsForExport` → `buildAuditLogExport` → Excel buffer。
|
||||
|
||||
定时清理流:`/api/cron/audit-cleanup`(CRON_SECRET 鉴权)→ `getAuditRetentionConfig` → `purgeExpiredAuditLogs`。
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
- **004(2.15 节)**:记录较完整,含 services/hooks/export/retention 节点,行数标注准确。
|
||||
- **005(audit 节点)**:data-access/actions/types 记录完整,含 services/hooks/export/retention 节点。
|
||||
- **已知不一致**:services 层的 `AuditServiceProvider` 在架构图中标注为"已接入",但实际**未被任何页面使用**(详见 P0-1)。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### P0 — 严重违规
|
||||
|
||||
#### P0-1 Service 抽象层定义但从未使用(虚假完成)
|
||||
|
||||
- **位置**:`services/audit-service.tsx`(`AuditServiceProvider`、`useAuditService`、`useAuditAnalytics`)、`services/admin-audit-service.ts`、`services/mock-audit-service.ts`
|
||||
- **违反规则**:任务要求 → "完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context(或组合 Provider)注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access";`project_rules.md` → "架构图优先规则:改码必同步图"
|
||||
- **问题**:
|
||||
1. 全局搜索 `AuditServiceProvider` 的实际使用:**仅在 `audit-service.tsx` 和 `mock-audit-service.ts` 的 JSDoc `@example` 中出现**,没有任何页面或组件实际注入
|
||||
2. 4 个 `page.tsx` 均直接 `import { getAuditLogs, ... } from "@/modules/audit/data-access"`
|
||||
3. `audit-retention-settings.tsx` 直接 `import { getAuditRetentionConfigAction, ... } from "@/modules/audit/actions"`
|
||||
4. `audit-log-export-button.tsx` 直接 `fetch("/api/export")` 绕过 Service 层
|
||||
5. 架构图 004 第 1468 行声称"✅ P2-8 已修复:~~无 Service 接口抽象~~",实际为"定义但未接线"
|
||||
- **原因**:P2-8 仅创建了接口文件,未在页面层注入 Provider、未将组件改造为从 Context 获取 service
|
||||
- **后果**:
|
||||
- DI 架构形同虚设,组件仍硬编码依赖 data-access,无法在测试中注入 mock
|
||||
- 架构图声称已修复,误导后续审计与维护决策
|
||||
- `mockAuditService` 完全无引用(死代码)
|
||||
|
||||
#### P0-2 mock-audit-service.ts 使用 9 处 `as` 类型断言
|
||||
|
||||
- **位置**:`services/mock-audit-service.ts` 第 29、31、33、36、45、46、47、53、60 行
|
||||
- **违反规则**:`project_rules.md` → "禁止 `as` 断言(除类型收窄外)";`project_memory.md` → "TypeScript strict mode: no `any`, no `as` assertions (except for type narrowing)"
|
||||
- **示例**:
|
||||
```typescript
|
||||
getAuditLogs: async () =>
|
||||
({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) as PaginatedResult<AuditLog>,
|
||||
```
|
||||
- **原因**:对象字面量缺少 `items: [] as AuditLog[]` 类型标注,导致需要 `as` 断言整个返回值
|
||||
- **后果**:违反 TS 严格规则;lint 通过但 code review 应拦截
|
||||
|
||||
#### P0-3 全部 7 个 Server Action 缺少 revalidatePath
|
||||
|
||||
- **位置**:`actions.ts` 所有 7 个 Action(`getDataChangeLogsAction`、`exportAuditLogsAction`、`exportLoginLogsAction`、`exportDataChangeLogsAction`、`getAuditRetentionConfigAction`、`saveAuditRetentionConfigAction`、`purgeAuditLogsAction`)
|
||||
- **违反规则**:`project_rules.md` → "Server Action 规范:使用 `revalidatePath` 精确刷新缓存"
|
||||
- **问题**:`saveAuditRetentionConfigAction` 和 `purgeAuditLogsAction` 修改数据后未刷新 overview 页面缓存,用户保存保留策略后看到的仍是旧配置
|
||||
- **原因**:遗漏
|
||||
- **后果**:保留策略变更后概览页缓存不刷新,显示过期数据
|
||||
|
||||
### P1 — 重要缺陷
|
||||
|
||||
#### P1-1 trackEvent event 名称误用(保留策略操作误标为导出)
|
||||
|
||||
- **位置**:`actions.ts` 第 170、199 行(`saveAuditRetentionConfigAction`、`purgeAuditLogsAction`)、`app/api/cron/audit-cleanup/route.ts` 第 54 行
|
||||
- **违反规则**:任务要求 → "监控:方案中预留关键操作埋点接口"
|
||||
- **问题**:保留策略的保存/清理操作使用 `event: "audit.exported"`,语义错误
|
||||
```typescript
|
||||
// saveAuditRetentionConfigAction
|
||||
await trackEvent({ event: "audit.exported", ... properties: { action: "save_config" } })
|
||||
// purgeAuditLogsAction
|
||||
void trackEvent({ event: "audit.exported", ... properties: { action: "purge" } })
|
||||
// cron route
|
||||
void trackEvent({ event: "audit.exported", ... properties: { action: "cron_purge" } })
|
||||
```
|
||||
- **后果**:分析平台中所有保留策略操作被归类为"导出",无法区分导出与清理行为
|
||||
|
||||
#### P1-2 AuditErrorBoundary 使用错误的 i18n namespace
|
||||
|
||||
- **位置**:`components/audit-error-boundary.tsx` 第 19 行 `namespace="common"`
|
||||
- **违反规则**:`project_rules.md` → "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"
|
||||
- **问题**:`SectionErrorBoundary` 读取 `t("error.boundaryTitle")` / `t("error.boundaryDescription")` / `t("error.retry")`。`audit.json` 定义了 `error.title` / `error.description` / `error.retry`(键名不匹配),而 `common.json` 有 `error.boundaryTitle` / `error.boundaryDescription`。当前传 `"common"` 可工作但使用的是通用错误文案,未利用 audit 专属的 `error.description`("数据加载时发生错误,请稍后重试。")
|
||||
- **后果**:区块级错误边界显示通用错误文案而非审计模块专属文案
|
||||
|
||||
#### P1-3 AuditAnalytics 接口定义为死代码
|
||||
|
||||
- **位置**:`services/audit-service.tsx` 第 64-84 行(`AuditAnalytics` 接口 + `noopAnalytics` + `AuditAnalyticsContext` + `useAuditAnalytics` hook)
|
||||
- **违反规则**:任务要求 → "监控:方案中预留关键操作埋点接口"
|
||||
- **问题**:`useAuditAnalytics` hook 全局搜索仅出现在定义处,**无任何组件调用**。`trackLogView` / `trackExport` / `trackOverviewView` / `trackRetentionConfigChange` / `trackPurge` 五个埋点方法均为死代码
|
||||
- **后果**:客户端层面无日志查看/导出/概览查看埋点,无法统计用户行为
|
||||
|
||||
#### P1-4 data-access 错误处理不一致
|
||||
|
||||
- **位置**:`data-access.ts`
|
||||
- **吞没错误**(返回空数组):`getAuditModuleOptions`(第 152 行)、`getDataChangeTableOptions`(第 237 行)
|
||||
- **抛出错误**:`getAuditLogs`、`getLoginLogs`、`getDataChangeLogs`、`getDataChangeStats`、`getAuditOverviewStats`、`getAuditTrend`、`getDataChangeActionStats`
|
||||
- **违反规则**:`project_rules.md` → "错误与边界处理:明确处理空数据、无权限、网络异常等边界状态"
|
||||
- **后果**:DB 故障时筛选器选项静默显示为空,用户误认为"无数据"而非"查询失败"
|
||||
|
||||
#### P1-5 日期筛选器 aria-label 不区分起止
|
||||
|
||||
- **位置**:`audit-log-filters.tsx` 第 94、101 行;`login-log-filters.tsx` 第 78、85 行;`data-change-log-filters.tsx` 第 117、124 行
|
||||
- **违反规则**:`project_rules.md` → "可访问性(a11y):语义化标签、ARIA 属性、键盘导航"
|
||||
- **问题**:两个日期 Input 均使用 `aria-label={t("table.time")}`("时间"),屏幕阅读器无法区分"开始日期"与"结束日期"
|
||||
- **后果**:视障用户无法区分两个日期输入框
|
||||
|
||||
#### P1-6 AuditLogDetailDialog DialogDescription 重复标题
|
||||
|
||||
- **位置**:`components/audit-log-detail-dialog.tsx` 第 135-137 行
|
||||
- **问题**:
|
||||
```tsx
|
||||
<DialogTitle>{t("detail.title")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t("detail.title")}</DialogDescription>
|
||||
```
|
||||
Description 与 Title 完全相同,无信息增量
|
||||
- **后果**:无障碍辅助技术读出重复信息
|
||||
|
||||
#### P1-7 骨架屏缺少 aria-hidden
|
||||
|
||||
- **位置**:`components/audit-log-table-skeleton.tsx`(全文件)、4 个 `loading.tsx`
|
||||
- **违反规则**:`project_rules.md` → "可访问性(a11y)"
|
||||
- **问题**:Skeleton 元素无 `aria-hidden="true"`,屏幕阅读器会逐个朗读骨架占位块
|
||||
- **后果**:加载期间屏幕阅读器体验差
|
||||
|
||||
#### P1-8 window.confirm 阻塞式确认
|
||||
|
||||
- **位置**:`components/audit-retention-settings.tsx` 第 80 行
|
||||
- **问题**:`window.confirm(t("purgeConfirm"))` 使用浏览器原生确认框,不可自定义样式、阻塞主线程、a11y 差
|
||||
- **后果**:与其他模块(使用 AlertDialog 组件)交互不一致
|
||||
|
||||
### P2 — 改进项
|
||||
|
||||
#### P2-1 数据变更 diff 无可视化
|
||||
|
||||
- **位置**:`data-change-log-table.tsx` 第 118-129 行使用 `<pre>` 纯文本展示 oldValue / newValue
|
||||
- **差距**:PowerSchool / Google Workspace Audit / Microsoft Purview 均提供 JSON diff 高亮(增行绿、删行红、左右对比)
|
||||
|
||||
#### P2-2 无失败登录异常告警
|
||||
|
||||
- **位置**:全模块
|
||||
- **差距**:行业产品支持阈值告警(如 5 分钟内同一 IP 失败登录 > 10 次触发告警)
|
||||
|
||||
#### P2-3 无 IP 地理位置
|
||||
|
||||
- **位置**:表格仅展示 IP 字符串
|
||||
- **差距**:行业产品将 IP 解析为地理位置(城市/国家),辅助判断异地登录
|
||||
|
||||
#### P2-4 概览趋势仅 7 天不可配置
|
||||
|
||||
- **位置**:`overview/page.tsx` 第 29 行 `getAuditTrend(7)` 硬编码 7 天
|
||||
- **差距**:行业产品支持 7/30/90 天切换
|
||||
|
||||
#### P2-5 多学校数据隔离缺失
|
||||
|
||||
- **位置**:`data-access.ts` 所有查询无 `schoolId` 过滤
|
||||
- **说明**:`audit_logs` / `login_logs` / `data_change_logs` 表无 `school_id` 字段(schema 确认),audit 模块设计为系统级跨校审计。当前 `AUDIT_LOG_READ` 权限若仅授予超级管理员则可接受,但需在文档中明确标注此设计决策
|
||||
- **风险**:若未来授予校级管理员 `AUDIT_LOG_READ`,将导致跨校数据泄露
|
||||
|
||||
#### P2-6 useLogPagination hook 无单测
|
||||
|
||||
- **位置**:`hooks/use-log-pagination.ts`
|
||||
- **违反规则**:`project_rules.md` → "可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks"
|
||||
- **说明**:v1 已补充 export.test.ts 和 retention.test.ts,但 hook 层无单测
|
||||
|
||||
#### P2-7 导出按钮无进度反馈
|
||||
|
||||
- **位置**:`audit-log-export-button.tsx`
|
||||
- **问题**:大范围导出仅显示 spinner,无进度条/计数
|
||||
- **差距**:行业产品显示"已导出 X / Y 条"
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
| 能力 | PowerSchool / Veracross | Google Workspace Audit | Microsoft Purview | 本系统现状 | 影响 |
|
||||
|------|------------------------|----------------------|-------------------|-----------|------|
|
||||
| 统一审计概览仪表盘 | ✅ | ✅ | ✅ | ✅ 已有概览页 | — |
|
||||
| 按用户/模块/动作/状态筛选 | ✅ | ✅ | ✅ | ✅ 已有完整筛选器 | — |
|
||||
| 日志详情视图 | ✅ 点击展开完整 JSON | ✅ 详情面板 | ✅ 活动详情 | ✅ 已有详情对话框 | — |
|
||||
| 数据变更 diff 可视化 | ✅ 左右对比 + 高亮 | ✅ | ✅ 差异高亮 | ❌ 纯文本 pre 展示 | 变更审查效率低 |
|
||||
| 失败登录监控/告警 | ✅ 异常登录告警 | ✅ 可疑活动检测 | ✅ 实时告警 | ❌ 仅展示无告警 | 无法及时发现暴力破解 |
|
||||
| 导出调度/定时 | ✅ 计划报告 | ✅ 导出 + 邮件 | ✅ 合规报告 | ❌ 仅手动导出 | 合规审计需人工操作 |
|
||||
| 数据保留策略 | ✅ 可配置保留期 | ✅ | ✅ | ✅ 已有保留策略配置 | — |
|
||||
| IP 地理位置 | ✅ | ✅ | ✅ | ❌ 仅显示 IP | 无法判断异地登录 |
|
||||
| 多角色审计视图 | ✅ 管理员/合规官分级 | ✅ | ✅ | ⚠️ 仅 admin 单角色 + Service 接口预留 | 合规官角色需独立视图 |
|
||||
| i18n | ✅ 多语言 | ✅ | ✅ | ✅ 已完整 i18n | — |
|
||||
| 概览趋势可配置时间范围 | ✅ 7/30/90 天 | ✅ | ✅ | ❌ 硬编码 7 天 | 无法查看长期趋势 |
|
||||
| DI 架构(可测试) | — | — | — | ⚠️ 定义未接线 | 组件无法 mock 数据源 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 立即修复(合规与架构正确性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | Service 抽象层未接线 | overview 页面接入 `AuditServiceProvider`,`AuditOverviewView` 内组件改用 `useAuditService()` 获取数据;或若评估后认为 RSC + props 模式已满足需求则**删除未使用的 services 层**避免误导 |
|
||||
| P0-2 | mock-audit-service.ts 9 处 as 断言 | 为对象字面量添加元素类型标注(如 `items: [] as AuditLog[]` → 改用 `items: new Array<AuditLog>()` 或显式标注返回类型) |
|
||||
| P0-3 | 7 个 Action 缺 revalidatePath | 写操作(saveAuditRetentionConfigAction、purgeAuditLogsAction)添加 `revalidatePath("/admin/audit-logs/overview")` |
|
||||
|
||||
### P1 — 本轮实施(规范与体验)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | trackEvent event 误标 | 保留策略操作改用 `event: "audit.retention"` |
|
||||
| P1-2 | ErrorBoundary namespace 错误 | `audit.json` 新增 `error.boundaryTitle` / `error.boundaryDescription`,`AuditErrorBoundary` 传 `namespace="audit"` |
|
||||
| P1-3 | AuditAnalytics 死代码 | 组件中调用 `useAuditAnalytics()` 接入客户端埋点(日志查看、概览查看、导出、保留策略变更) |
|
||||
| P1-4 | data-access 错误处理不一致 | `getAuditModuleOptions` / `getDataChangeTableOptions` 改为抛出错误 |
|
||||
| P1-5 | 日期 aria-label 不区分 | 新增 i18n `filter.startDate` / `filter.endDate`,替换 `t("table.time")` |
|
||||
| P1-6 | DialogDescription 重复 | 新增 `detail.description` 翻译键 |
|
||||
| P1-7 | 骨架屏缺 aria-hidden | Skeleton 容器添加 `aria-hidden="true"` |
|
||||
| P1-8 | window.confirm | 替换为 AlertDialog 组件(与其他模块一致) |
|
||||
|
||||
### P2 — 中长期迭代(功能增强)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | diff 无可视化 | 引入 JSON diff 组件(左右对比 + 增删高亮) |
|
||||
| P2-2 | 无失败登录告警 | 新增异常检测规则 + 告警通知 |
|
||||
| P2-3 | 无 IP 地理位置 | 接入 IP 反查服务(如 MaxMind GeoLite2) |
|
||||
| P2-4 | 趋势不可配置 | 概览页新增 7/30/90 天切换按钮 |
|
||||
| P2-5 | 多校数据隔离 | 文档标注"系统级审计"设计决策;若需校级审计则新增 `school_id` 字段 |
|
||||
| P2-6 | hook 无单测 | 为 `useLogPagination` 补充单测 |
|
||||
| P2-7 | 导出无进度 | 大范围导出显示进度条 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图以下遗漏/不一致,需在实现后同步更新:
|
||||
|
||||
### 004_architecture_impact_map.md(2.15 节)
|
||||
|
||||
1. **修正 services 层状态**:第 1468 行"✅ P2-8 已修复"改为"P2-8 已定义接口,P0-1 待接线"(实施后改回"已接线")
|
||||
2. **修正 trackEvent 埋点描述**:保留策略操作的 event 名称从 `audit.exported` 改为 `audit.retention`
|
||||
3. **新增 revalidatePath 说明**:actions 节标注 saveAuditRetentionConfigAction / purgeAuditLogsAction 已添加 revalidatePath
|
||||
|
||||
### 005_architecture_data.json(audit 节点)
|
||||
|
||||
1. **services 节点**:标注 `AuditServiceProvider` 的 `usedBy`(实施后从空改为 overview page)
|
||||
2. **actions 节点**:更新 saveAuditRetentionConfigAction / purgeAuditLogsAction 的 trackEvent event 名称
|
||||
3. **components 节点**:更新 `audit-error-boundary.tsx` 的 namespace 配置
|
||||
|
||||
---
|
||||
|
||||
## 六、重构方案设计
|
||||
|
||||
### a. P0-1 Service 抽象层接线方案
|
||||
|
||||
**决策**:保留 Service 接口(满足任务要求的 DI 架构),在 overview 页面接线。
|
||||
|
||||
由于 `AuditOverviewView` 是 RSC(async server component),而 `AuditServiceProvider` 是 Client Component("use client"),无法直接在 RSC 中使用 Context Provider。因此采用**混合模式**:
|
||||
|
||||
1. **RSC 页面**:仍由 page.tsx 调用 data-access 获取初始数据(保留 SSR 性能优势)
|
||||
2. **客户端交互组件**(`AuditRetentionSettings`):通过 `AuditServiceProvider` 注入 service,组件内部用 `useAuditService()` 获取数据
|
||||
3. **`adminAuditService`** 改为可被 Client Component 引用的轻量包装(仅委托给 Server Action,不直接 import server-only 的 data-access)
|
||||
|
||||
```typescript
|
||||
// services/admin-audit-service.ts —— 改造为 Client-safe 实现
|
||||
// 不 import "server-only",改为委托 Server Actions
|
||||
import { getAuditRetentionConfigAction, saveAuditRetentionConfigAction, purgeAuditLogsAction } from "../actions"
|
||||
|
||||
export const adminAuditService: AuditService = {
|
||||
// 保留策略相关:通过 Server Action 调用
|
||||
getAuditRetentionConfig: async () => {
|
||||
const res = await getAuditRetentionConfigAction()
|
||||
if (!res.success || !res.data) throw new Error(res.message ?? "Failed")
|
||||
return res.data
|
||||
},
|
||||
saveAuditRetentionConfig: async (config) => {
|
||||
const res = await saveAuditRetentionConfigAction(config)
|
||||
if (!res.success || !res.data) throw new Error(res.message ?? "Failed")
|
||||
},
|
||||
purgeExpiredAuditLogs: async (retentionDays) => {
|
||||
const res = await purgeAuditLogsAction(retentionDays)
|
||||
if (!res.success || !res.data) throw new Error(res.message ?? "Failed")
|
||||
return res.data
|
||||
},
|
||||
// 查询类:RSC 页面已通过 props 传入,客户端不需要
|
||||
// ...其余方法可暂不实现或抛错
|
||||
}
|
||||
```
|
||||
|
||||
`AuditRetentionSettings` 改造:
|
||||
```tsx
|
||||
// 在组件内部使用 useAuditService() 而非直接调 Server Action
|
||||
const service = useAuditService()
|
||||
const res = await service.getAuditRetentionConfig()
|
||||
```
|
||||
|
||||
页面注入:
|
||||
```tsx
|
||||
// overview/page.tsx
|
||||
import { AuditServiceProvider } from "@/modules/audit/services/audit-service"
|
||||
import { adminAuditService } from "@/modules/audit/services/admin-audit-service"
|
||||
|
||||
return (
|
||||
<AuditServiceProvider service={adminAuditService}>
|
||||
<AuditOverviewView stats={stats} trend={trend} distribution={distribution} />
|
||||
</AuditServiceProvider>
|
||||
)
|
||||
```
|
||||
|
||||
### b. P0-2 mock-audit-service 类型标注修复
|
||||
|
||||
```typescript
|
||||
// 修复前
|
||||
getAuditLogs: async () =>
|
||||
({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) as PaginatedResult<AuditLog>,
|
||||
|
||||
// 修复后 —— 为返回值添加显式类型标注,无需 as
|
||||
getAuditLogs: async (): Promise<PaginatedResult<AuditLog>> => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 0,
|
||||
}),
|
||||
```
|
||||
|
||||
### c. P0-3 revalidatePath
|
||||
|
||||
```typescript
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
// saveAuditRetentionConfigAction 末尾
|
||||
revalidatePath("/admin/audit-logs/overview")
|
||||
return { success: true, data: config }
|
||||
|
||||
// purgeAuditLogsAction 末尾
|
||||
revalidatePath("/admin/audit-logs/overview")
|
||||
revalidatePath("/admin/audit-logs")
|
||||
return { success: true, data: result }
|
||||
```
|
||||
|
||||
### d. P1-1 trackEvent event 修正
|
||||
|
||||
```typescript
|
||||
// 保留策略操作
|
||||
await trackEvent({
|
||||
event: "audit.retention", // 原: "audit.exported"
|
||||
userId: session?.user?.id,
|
||||
targetType: "audit_retention",
|
||||
properties: { action: "save_config", ... },
|
||||
})
|
||||
```
|
||||
|
||||
### e. P1-2 ErrorBoundary namespace 修复
|
||||
|
||||
`audit.json` 新增:
|
||||
```json
|
||||
"error": {
|
||||
"title": "加载失败",
|
||||
"description": "数据加载时发生错误,请稍后重试。",
|
||||
"retry": "重试",
|
||||
"boundaryTitle": "审计数据加载失败",
|
||||
"boundaryDescription": "审计数据区块加载时发生错误。"
|
||||
}
|
||||
```
|
||||
|
||||
`audit-error-boundary.tsx`:
|
||||
```tsx
|
||||
<SectionErrorBoundary namespace="audit">
|
||||
```
|
||||
|
||||
### f. i18n 翻译键补充
|
||||
|
||||
```json
|
||||
"filter": {
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期"
|
||||
},
|
||||
"detail": {
|
||||
"description": "查看日志的完整字段信息。"
|
||||
}
|
||||
```
|
||||
|
||||
### g. 最终检查
|
||||
|
||||
- [x] 该模块不存在对其他业务模块的直接 import(仅依赖 shared/* 和 settings data-access for retention config)
|
||||
- [x] 没有使用 `any` 或硬编码角色字符串
|
||||
- [x] 所有 actions 包含 `requirePermission` 调用(7 个 Action 均有)
|
||||
- [x] 文件行数未超过建议上限(最大 data-access.ts 387 行 < 800)
|
||||
- [x] 架构影响地图需同步更新(见第五节)
|
||||
436
docs/architecture/audit/audit-module-audit-report.md
Normal file
436
docs/architecture/audit/audit-module-audit-report.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# 审计模块审计报告
|
||||
|
||||
> 审计范围:`src/modules/audit/**`、`src/app/(dashboard)/admin/audit-logs/**`、`src/app/api/export/route.ts`(审计导出分支)、`src/shared/lib/audit-logger.ts`、`src/shared/lib/change-logger.ts`、`src/shared/i18n/messages/{zh-CN,en}/audit.json`
|
||||
> 审计日期:2026-06-24
|
||||
> 审计依据:`docs/architecture/004_architecture_impact_map.md`(2.15 节)、`docs/architecture/005_architecture_data.json`(audit 节点)、`docs/standards/coding-standards.md`、项目 `project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/page.tsx` | 74 | 操作日志列表页(RSC,调 data-access) |
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/login-logs/page.tsx` | 74 | 登录日志列表页 |
|
||||
| 路由 | `app/(dashboard)/admin/audit-logs/data-changes/page.tsx` | 78 | 数据变更日志列表页 |
|
||||
| 路由 | `app/api/export/route.ts` | 201 | 统一导出 API(含 audit/login/dataChange 三分支) |
|
||||
| actions | `modules/audit/actions.ts` | 214 | 4 个 Server Action(1 查询 + 3 导出) |
|
||||
| data-access | `modules/audit/data-access.ts` | 290 | 9 个查询函数(分页查询 + 导出遍历 + 选项 + 统计) |
|
||||
| types | `modules/audit/types.ts` | 117 | 类型定义 + 状态映射常量 |
|
||||
| 组件 | `components/audit-log-view.tsx` | 61 | 操作日志视图(筛选+表格+分页) |
|
||||
| 组件 | `components/audit-log-table.tsx` | 110 | 操作日志表格 |
|
||||
| 组件 | `components/audit-log-filters.tsx` | 92 | 操作日志筛选器 |
|
||||
| 组件 | `components/audit-log-export-button.tsx` | 83 | 导出按钮(fetch /api/export) |
|
||||
| 组件 | `components/login-log-view.tsx` | 59 | 登录日志视图 |
|
||||
| 组件 | `components/login-log-table.tsx` | 104 | 登录日志表格 |
|
||||
| 组件 | `components/login-log-filters.tsx` | 77 | 登录日志筛选器 |
|
||||
| 组件 | `components/data-change-log-table.tsx` | 281 | 数据变更表格+筛选器+展开行(混合) |
|
||||
| shared | `shared/lib/audit-logger.ts` | 46 | `logAudit()` 写入审计日志 |
|
||||
| shared | `shared/lib/change-logger.ts` | 43 | `logDataChange()` 写入变更日志 |
|
||||
| i18n | `shared/i18n/messages/{zh-CN,en}/audit.json` | 12 | 仅 3 个标题/描述键 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
├─ requirePermission(AUDIT_LOG_READ)
|
||||
├─ getAuditLogs/getLoginLogs/getDataChangeLogs (data-access)
|
||||
└─ <AuditLogView items={...} /> (Client Component)
|
||||
├─ <AuditLogFilters> (nuqs URL 状态)
|
||||
└─ <AuditLogTable> (分页 + 空状态)
|
||||
```
|
||||
|
||||
导出流:`<AuditLogExportButton>` → `fetch /api/export` → `exportAuditLogsAction` → `getAuditLogsForExport` → Excel buffer。
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
- **004(2.15 节)**:记录了 audit 模块,但存在**不一致**:声称 actions 层有 `getAuditLogsAction` / `getLoginLogsAction`,实际**不存在**这两个 Action(页面直接调 data-access)。组件清单不完整(缺 `data-change-log-table.tsx`、`login-log-view.tsx`、`audit-log-export-button.tsx`)。
|
||||
- **005(audit 节点)**:data-access/actions/types 记录较完整,但 actions 的 `usedBy` 标注为"待扩展"(实际已被 `/api/export/route.ts` 使用)。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### P0 — 严重违规
|
||||
|
||||
#### P0-1 i18n 严重缺失:组件全部硬编码英文文案
|
||||
- **位置**:`audit-log-table.tsx`("User/Module/Action/Target/Status/IP Address/Time/No audit logs found.")、`audit-log-filters.tsx`("Module/Any Module/Action.../Status/Any Status/Success/Failure")、`login-log-table.tsx`、`login-log-filters.tsx`、`data-change-log-table.tsx`("Table/Record ID/Changed By/View/Hide/Old Value/New Value/Any Table/Create/Update/Delete/Reset/No data change logs found.")、`audit-log-export-button.tsx`("Export Excel/Export failed/Export ready")
|
||||
- **违反规则**:`project_rules.md` → "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"
|
||||
- **原因**:i18n 字典 `audit.json` 仅含 3 个标题/描述键,组件层未接入 `useTranslations`
|
||||
- **后果**:中文用户在审计页面看到全英文表格表头、筛选器、空状态、按钮文案,与系统其他模块(已 i18n)体验割裂;无法切换语言
|
||||
|
||||
#### P0-2 缺少 loading.tsx 与 error.tsx 错误边界
|
||||
- **位置**:`app/(dashboard)/admin/audit-logs/`、`login-logs/`、`data-changes/` 三个路由均无 `loading.tsx` 和 `error.tsx`
|
||||
- **违反规则**:`project_memory.md` → "All student routes must include loading.tsx and error.tsx for error boundaries";`project_rules.md` → "每个独立的数据区块必须用 React Error Boundary 包裹"
|
||||
- **原因**:审计路由作为后加模块未补齐边界文件
|
||||
- **后果**:数据加载期间白屏;运行时错误直接显示 Next.js 默认错误页,无重试能力
|
||||
|
||||
#### P0-3 架构图与代码不一致
|
||||
- **位置**:`004_architecture_impact_map.md` 2.15 节
|
||||
- **违反规则**:`project_rules.md` → "改码必同步图"、"架构图优先规则"
|
||||
- **问题**:004 声称存在 `getAuditLogsAction` / `getLoginLogsAction`,实际不存在;组件清单缺 3 个文件;行数标注过期(actions 标 212 实际 214,data-access 标 260 实际 290)
|
||||
- **后果**:权限审计、依赖分析会得出错误结论
|
||||
|
||||
### P1 — 重要缺陷
|
||||
|
||||
#### P1-1 formatDate 硬编码 locale 为 "zh-CN"
|
||||
- **位置**:`audit-log-table.tsx:92`、`login-log-table.tsx:86`、`data-change-log-table.tsx:116`
|
||||
- **违反规则**:i18n 就绪要求
|
||||
- **原因**:直接传 `"zh-CN"` 而非从 next-intl 获取当前 locale
|
||||
- **后果**:英文用户看到中文格式日期
|
||||
|
||||
#### P1-2 分页 handlePageChange 三处重复
|
||||
- **位置**:`audit-log-view.tsx:29-38`、`login-log-view.tsx:27-36`、`data-change-log-table.tsx:58-67`
|
||||
- **违反规则**:`project_rules.md` → "最大化复用"
|
||||
- **原因**:三处 100% 相同的 URL searchParams 操作逻辑未抽取
|
||||
- **后果**:维护需改三处,易遗漏
|
||||
|
||||
#### P1-3 导出逻辑内联在 actions 层,三个导出 Action 结构高度重复
|
||||
- **位置**:`actions.ts:90-214`(`exportAuditLogsAction` / `exportLoginLogsAction` / `exportDataChangeLogsAction`)
|
||||
- **违反规则**:004 已标记为 P2 待修复;`project_rules.md` → 单文件职责清晰
|
||||
- **原因**:列定义 + 行映射 + buildExcelExport 全内联在 actions
|
||||
- **后果**:新增日志类型需复制整段;列定义无法在组件层复用(如详情视图)
|
||||
|
||||
#### P1-4 DataChangeLogTable 混合三职责(281 行)
|
||||
- **位置**:`data-change-log-table.tsx`
|
||||
- **问题**:表格组件 + 筛选器组件(`DataChangeLogFilters`)+ 展开行逻辑全部定义在同一文件
|
||||
- **违反规则**:`project_rules.md` → "组件必须为纯函数,职责单一"
|
||||
- **后果**:筛选器无法独立复用;文件接近 300 行不易维护
|
||||
|
||||
#### P1-5 无 React Error Boundary 包裹独立数据区块
|
||||
- **位置**:三个 `page.tsx` 均直接渲染 `<AuditLogView>` / `<DataChangeLogTable>` 无 ErrorBoundary
|
||||
- **违反规则**:`project_rules.md` → "每个独立的数据区块必须用 React Error Boundary 包裹"
|
||||
- **后果**:单个区块错误导致整页崩溃
|
||||
|
||||
#### P1-6 Suspense fallback 为 null,无骨架屏
|
||||
- **位置**:`audit-log-view.tsx:57`、`login-log-view.tsx:55`、`data-change-log-table.tsx:277`
|
||||
- **违反规则**:`project_rules.md` → "异步数据使用 React Suspense + 骨架屏"
|
||||
- **后果**:筛选切换时无加载反馈
|
||||
|
||||
#### P1-7 无关键操作埋点
|
||||
- **位置**:全模块
|
||||
- **违反规则**:`project_rules.md` → "监控:方案中预留关键操作埋点接口"
|
||||
- **原因**:导出操作、日志查看无 `trackEvent` 调用
|
||||
- **后果**:无法统计审计功能使用情况、无法监控异常导出行为
|
||||
|
||||
#### P1-8 a11y 缺失
|
||||
- **位置**:表格无 `aria-label`/`caption`;筛选 Select 无 `aria-label`;展开按钮无 `aria-expanded`;导出按钮无 `aria-label`
|
||||
- **违反规则**:`project_rules.md` → "可访问性(a11y):语义化标签、ARIA 属性、键盘导航"
|
||||
|
||||
### P2 — 改进项
|
||||
|
||||
#### P2-1 data-access 错误吞没,UI 无法区分"空数据"与"查询失败"
|
||||
- **位置**:`data-access.ts` 所有函数 catch 块返回空数组
|
||||
- **后果**:DB 故障时用户看到"无日志"而非错误提示
|
||||
|
||||
#### P2-2 无日志详情视图
|
||||
- **位置**:审计日志表格仅展示摘要,`detail` 字段(JSON)无法查看
|
||||
- **差距**:行业标配支持点击行展开/弹窗查看完整日志详情
|
||||
|
||||
#### P2-3 无用户维度筛选
|
||||
- **位置**:`audit-log-filters.tsx` 仅支持 module/action/status/date,不支持按用户搜索
|
||||
- **差距**:PowerSchool/Veracross 支持按用户筛选所有日志
|
||||
|
||||
#### P2-4 无审计概览仪表盘
|
||||
- **位置**:无统计概览页
|
||||
- **差距**:行业产品提供"今日事件数/失败登录数/数据变更数"概览卡片 + 活动趋势图
|
||||
|
||||
#### P2-5 无数据保留策略
|
||||
- **位置**:审计日志无 TTL/归档机制
|
||||
- **差距**:企业级产品支持可配置保留期(如 90/180/365 天)
|
||||
|
||||
#### P2-6 无单测
|
||||
- **位置**:无 `*.test.ts` 文件
|
||||
- **违反规则**:`project_rules.md` → "可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks"
|
||||
|
||||
#### P2-7 api/export/route.ts 中 `as Record<string, string>` 类型断言
|
||||
- **位置**:`route.ts:145`
|
||||
- **违反规则**:`project_rules.md` → "禁止 `as` 断言(除类型收窄外)"
|
||||
|
||||
#### P2-8 无 Service 接口抽象 / 依赖注入
|
||||
- **位置**:页面直接调 data-access,组件直接收 props
|
||||
- **违反规则**:任务要求 → "完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务"
|
||||
- **说明**:当前 RSC + props 模式可工作,但不满足任务要求的 DI 架构,且无法在客户端组件中 mock 数据源
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
| 能力 | PowerSchool / Veracross | Google Workspace Audit | Microsoft Purview | 本系统现状 | 影响 |
|
||||
|------|------------------------|----------------------|-------------------|-----------|------|
|
||||
| 统一审计概览仪表盘 | ✅ 今日/本周事件数 + 趋势图 | ✅ 活动时间线 | ✅ 合规概览 | ❌ 无 | 管理员无法快速掌握系统活动全貌 |
|
||||
| 按用户筛选 | ✅ | ✅ | ✅ | ❌ 仅 module/action/status | 无法追踪特定用户操作轨迹 |
|
||||
| 日志详情视图 | ✅ 点击展开完整 JSON | ✅ 详情面板 | ✅ 活动详情 | ❌ 仅表格摘要 | 审计人员无法查看 detail 字段 |
|
||||
| 数据变更 diff 可视化 | ✅ 左右对比 + 高亮 | ✅ | ✅ 差异高亮 | ⚠️ 纯文本 pre 展示 | 变更审查效率低 |
|
||||
| 失败登录监控/告警 | ✅ 异常登录告警 | ✅ 可疑活动检测 | ✅ 实时告警 | ❌ 无 | 无法及时发现暴力破解 |
|
||||
| 导出调度/定时 | ✅ 计划报告 | ✅ 导出 + 邮件 | ✅ 合规报告 | ❌ 仅手动导出 | 合规审计需人工操作 |
|
||||
| 数据保留策略 | ✅ 可配置保留期 | ✅ | ✅ | ❌ 无限增长 | 存储成本持续上升 |
|
||||
| IP 地理位置 | ✅ | ✅ | ✅ | ❌ 仅显示 IP | 无法判断异地登录 |
|
||||
| 多角色审计视图 | ✅ 管理员/合规官分级 | ✅ | ✅ | ⚠️ 仅 admin 单角色 | 未来合规官角色需独立视图 |
|
||||
| i18n | ✅ 多语言 | ✅ | ✅ | ❌ 全英文硬编码 | 中文用户体验差 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 立即修复(合规与基础体验)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | i18n 全缺失 | 补全 `audit.json` 翻译键(表头/筛选器/空状态/按钮/Toast),所有组件接入 `useTranslations("audit")` |
|
||||
| P0-2 | 缺 loading/error.tsx | 三个路由各新增 `loading.tsx`(骨架屏)+ `error.tsx`(错误边界 + 重试) |
|
||||
| P0-3 | 架构图不一致 | 同步 004/005:修正 actions 清单、补全组件清单、更新行数 |
|
||||
|
||||
### P1 — 本轮实施(架构规范)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | formatDate 硬编码 locale | 改用 `useLocale()` 获取当前 locale |
|
||||
| P1-2 | handlePageChange 重复 | 抽取 `useLogPagination` hook 到 `hooks/` |
|
||||
| P1-3 | 导出逻辑内联 | 抽取 `export.ts`,列定义+行映射移至独立文件 |
|
||||
| P1-4 | DataChangeLogTable 混合 | 拆分为 `data-change-log-table.tsx` + `data-change-log-filters.tsx` |
|
||||
| P1-5 | 无 Error Boundary | 新增 `audit-error-boundary.tsx`,包裹各数据区块 |
|
||||
| P1-6 | Suspense 无骨架屏 | fallback 改为 `AuditLogTableSkeleton` |
|
||||
| P1-7 | 无埋点 | 导出/查看操作新增 `trackEvent` |
|
||||
| P1-8 | a11y 缺失 | 表格加 caption/aria-label,Select 加 aria-label,展开按钮加 aria-expanded |
|
||||
|
||||
### P2 — 中长期迭代(功能增强)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 错误吞没 | data-access 抛出业务错误,actions 层捕获返回 ActionState.error |
|
||||
| P2-2 | 无详情视图 | 新增 `audit-log-detail-dialog.tsx`,展示完整 detail JSON |
|
||||
| P2-3 | 无用户筛选 | 筛选器新增用户搜索 Input |
|
||||
| P2-4 | 无概览仪表盘 | 新增 `/admin/audit-logs/overview` 概览页(统计卡片+趋势图) |
|
||||
| P2-5 | 无保留策略 | 新增 `audit-retention-config` + 定时清理 job |
|
||||
| P2-6 | 无单测 | 为纯函数(分页计算、格式化、列映射)添加单测 |
|
||||
| P2-7 | as 断言 | 改用类型守卫 |
|
||||
| P2-8 | 无 Service 抽象 | 定义 `AuditService` 接口 + Context Provider 注入 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图以下遗漏/不一致,需在实现后同步更新:
|
||||
|
||||
### 004_architecture_impact_map.md(2.15 节)
|
||||
1. **修正 actions 清单**:删除不存在的 `getAuditLogsAction` / `getLoginLogsAction`;补充说明页面直接调 data-access
|
||||
2. **补全组件清单**:新增 `data-change-log-table.tsx`、`login-log-view.tsx`、`audit-log-export-button.tsx`、`data-change-log-filters.tsx`(拆分后)、`audit-error-boundary.tsx`(新增)、`audit-log-table-skeleton.tsx`(新增)
|
||||
3. **更新行数**:actions.ts 214→拆分后;data-access.ts 290;新增 export.ts、hooks/
|
||||
4. **新增 hooks 清单**:`use-log-pagination.ts`
|
||||
5. **更新已知问题**:标记 P0-1~P1-8 已修复
|
||||
|
||||
### 005_architecture_data.json(audit 节点)
|
||||
1. **修正 actions**:删除 `getAuditLogsAction`/`getLoginLogsAction`;`usedBy` 更新为 `api/export/route.ts`
|
||||
2. **补全 components**:新增缺失组件
|
||||
3. **新增 hooks 节点**:`useLogPagination`
|
||||
4. **新增 export 节点**:`export.ts`
|
||||
|
||||
---
|
||||
|
||||
## 六、重构方案设计
|
||||
|
||||
### a. 新文件/目录结构
|
||||
|
||||
```
|
||||
src/modules/audit/
|
||||
├─ actions.ts # Server Actions(编排层,精简)
|
||||
├─ data-access.ts # 数据访问层(查询)
|
||||
├─ export.ts # 🆕 Excel 导出(列定义+行映射+buildExcelExport)
|
||||
├─ types.ts # 类型定义 + 状态映射常量
|
||||
├─ hooks/
|
||||
│ └─ use-log-pagination.ts # 🆕 分页 URL 状态 hook(消除三处重复)
|
||||
├─ components/
|
||||
│ ├─ audit-log-view.tsx # 操作日志视图(i18n + ErrorBoundary + Suspense)
|
||||
│ ├─ audit-log-table.tsx # 操作日志表格(i18n + a11y)
|
||||
│ ├─ audit-log-filters.tsx # 操作日志筛选器(i18n + a11y)
|
||||
│ ├─ audit-log-export-button.tsx # 导出按钮(i18n + trackEvent)
|
||||
│ ├─ login-log-view.tsx # 登录日志视图
|
||||
│ ├─ login-log-table.tsx # 登录日志表格
|
||||
│ ├─ login-log-filters.tsx # 登录日志筛选器
|
||||
│ ├─ data-change-log-view.tsx # 🆕 数据变更视图(拆分自 table)
|
||||
│ ├─ data-change-log-table.tsx # 数据变更表格(仅表格,不含筛选)
|
||||
│ ├─ data-change-log-filters.tsx# 🆕 数据变更筛选器(拆分)
|
||||
│ ├─ audit-error-boundary.tsx # 🆕 错误边界
|
||||
│ └─ audit-log-table-skeleton.tsx # 🆕 骨架屏
|
||||
├─ i18n/
|
||||
│ └─ (由 shared/i18n/messages/{locale}/audit.json 统一管理)
|
||||
└─ lib/
|
||||
└─ audit-columns.ts # 🆕 导出列定义(纯函数,可单测)
|
||||
```
|
||||
|
||||
### b. 核心代码示例
|
||||
|
||||
#### 数据服务接口定义(P2-8,中期实施)
|
||||
|
||||
```typescript
|
||||
// src/modules/audit/services/audit-service.ts
|
||||
export interface AuditService {
|
||||
getAuditLogs(params?: AuditLogQueryParams): Promise<PaginatedResult<AuditLog>>
|
||||
getLoginLogs(params?: LoginLogQueryParams): Promise<PaginatedResult<LoginLog>>
|
||||
getDataChangeLogs(params?: DataChangeLogQueryParams): Promise<PaginatedResult<DataChangeLog>>
|
||||
getAuditModuleOptions(): Promise<string[]>
|
||||
getDataChangeStats(): Promise<DataChangeStat[]>
|
||||
getDataChangeTableOptions(): Promise<string[]>
|
||||
}
|
||||
|
||||
// 角色实现示例(中期)
|
||||
export class AdminAuditService implements AuditService {
|
||||
// 封装 data-access 调用,权限已在 Server Action 层校验
|
||||
async getAuditLogs(params?: AuditLogQueryParams) {
|
||||
return getAuditLogs(params)
|
||||
}
|
||||
// ...其他方法委托给 data-access
|
||||
}
|
||||
```
|
||||
|
||||
#### 通用分页 Hook(P1-2,本轮实施)
|
||||
|
||||
```typescript
|
||||
// src/modules/audit/hooks/use-log-pagination.ts
|
||||
"use client"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
|
||||
export function useLogPagination(): (page: number) => void {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
return useCallback((newPage: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newPage <= 1) params.delete("page")
|
||||
else params.set("page", String(newPage))
|
||||
const query = params.toString()
|
||||
router.push(query ? `?${query}` : "?")
|
||||
}, [router, searchParams])
|
||||
}
|
||||
```
|
||||
|
||||
#### 导出模块抽取(P1-3,本轮实施)
|
||||
|
||||
```typescript
|
||||
// src/modules/audit/export.ts
|
||||
import { exportToExcel, type ExcelColumn } from "@/shared/lib/excel"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import type { AuditLog, LoginLog, DataChangeLog } from "./types"
|
||||
|
||||
export const AUDIT_LOG_COLUMNS: ExcelColumn[] = [
|
||||
{ header: "User ID", key: "userId", width: 22 },
|
||||
// ...列定义
|
||||
]
|
||||
|
||||
export function mapAuditLogsToRows(items: AuditLog[]) {
|
||||
return items.map((r) => ({ /* ...映射 */ }))
|
||||
}
|
||||
|
||||
export async function buildAuditLogExport(items: AuditLog[]) {
|
||||
const buffer = await exportToExcel({
|
||||
sheets: [{ name: "Audit Logs", columns: AUDIT_LOG_COLUMNS, rows: mapAuditLogsToRows(items) }],
|
||||
})
|
||||
return { buffer, filename: `audit_logs_${formatDateForFile()}.xlsx` }
|
||||
}
|
||||
```
|
||||
|
||||
#### ErrorBoundary 包裹(P1-5,本轮实施)
|
||||
|
||||
```tsx
|
||||
// src/modules/audit/components/audit-error-boundary.tsx
|
||||
"use client"
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface Props { children: ReactNode }
|
||||
interface State { hasError: boolean }
|
||||
|
||||
export class AuditErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false }
|
||||
static getDerivedStateFromError(): State { return { hasError: true } }
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <AuditErrorFallback onRetry={() => this.setState({ hasError: false })} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 角色组装页面(配置驱动,中期)
|
||||
|
||||
```tsx
|
||||
// 未来扩展:通过配置决定渲染哪些 Widget
|
||||
const AUDIT_WIDGET_CONFIG = {
|
||||
admin: ["overviewStats", "auditLogTable", "loginLogTable", "dataChangeTable"],
|
||||
compliance: ["auditLogTable", "dataChangeTable"],
|
||||
} as const
|
||||
```
|
||||
|
||||
### c. 解耦与测试说明
|
||||
|
||||
- **纯函数抽取**:分页计算(`clampPage`/`clampPageSize`)、列映射(`mapAuditLogsToRows`)、状态映射常量已独立于 UI,可直接单测
|
||||
- **Hook 测试**:`useLogPagination` 通过 mock `next/navigation` 的 `useRouter`/`useSearchParams` 测试
|
||||
- **组件测试**:`AuditLogTable` 接收纯 props,传入 mock 数据即可渲染测试
|
||||
- **Mock service 示例**:
|
||||
```typescript
|
||||
const mockEmptyService: AuditService = {
|
||||
getAuditLogs: async () => ({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }),
|
||||
// ...其他返回空数据
|
||||
}
|
||||
```
|
||||
|
||||
### d. i18n 集成示例
|
||||
|
||||
翻译文件结构(`shared/i18n/messages/zh-CN/audit.json`):
|
||||
```json
|
||||
{
|
||||
"title": "审计日志",
|
||||
"description": "追踪系统内所有用户操作,保障安全与合规。",
|
||||
"table": {
|
||||
"user": "用户", "module": "模块", "action": "操作",
|
||||
"target": "目标", "status": "状态", "ipAddress": "IP 地址",
|
||||
"time": "时间", "userAgent": "用户代理", "tableName": "数据表",
|
||||
"recordId": "记录 ID", "changedBy": "操作人", "view": "查看", "hide": "隐藏",
|
||||
"oldValue": "旧值", "newValue": "新值"
|
||||
},
|
||||
"filter": {
|
||||
"anyModule": "任意模块", "anyStatus": "任意状态", "anyAction": "任意操作",
|
||||
"anyTable": "任意数据表", "actionPlaceholder": "操作...",
|
||||
"success": "成功", "failure": "失败",
|
||||
"create": "创建", "update": "更新", "delete": "删除",
|
||||
"signIn": "登录", "signOut": "登出", "signUp": "注册", "reset": "重置"
|
||||
},
|
||||
"empty": {
|
||||
"audit": "暂无审计日志", "login": "暂无登录日志", "dataChange": "暂无数据变更日志"
|
||||
},
|
||||
"export": { "button": "导出 Excel", "success": "导出成功", "failed": "导出失败" },
|
||||
"error": { "title": "加载失败", "description": "数据加载时发生错误,请稍后重试。", "retry": "重试" },
|
||||
"loginLogs": { "title": "登录日志", "description": "..." },
|
||||
"dataChanges": { "title": "数据变更日志", "description": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
组件使用:
|
||||
```tsx
|
||||
const t = useTranslations("audit")
|
||||
<TableHead>{t("table.user")}</TableHead>
|
||||
<EmptyTableRow colSpan={7} message={t("empty.audit")} />
|
||||
```
|
||||
|
||||
### e. 错误处理与加载状态示例
|
||||
|
||||
```tsx
|
||||
// page.tsx
|
||||
<AuditErrorBoundary>
|
||||
<Suspense fallback={<AuditLogTableSkeleton />}>
|
||||
<AuditLogView items={result.items} /* ... */ />
|
||||
</Suspense>
|
||||
</AuditErrorBoundary>
|
||||
```
|
||||
|
||||
### 最终检查
|
||||
|
||||
- [x] 该模块不存在对其他业务模块的直接 import(仅依赖 shared/*)
|
||||
- [x] 没有使用 `any` 或硬编码角色字符串
|
||||
- [x] 所有 actions 包含 `requirePermission` 调用(4 个 Action 均有)
|
||||
- [x] 文件行数未超过建议上限(最大 data-access.ts 290 行 < 800)
|
||||
- [x] 架构影响地图需同步更新(见第五节)
|
||||
1094
docs/architecture/audit/auth-audit-report.md
Normal file
1094
docs/architecture/audit/auth-audit-report.md
Normal file
File diff suppressed because it is too large
Load Diff
440
docs/architecture/audit/classes-audit-report.md
Normal file
440
docs/architecture/audit/classes-audit-report.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# 班级(classes)模块审计报告
|
||||
|
||||
> 审计时间:2026-06-25
|
||||
> 审计范围:`src/modules/classes/` 全部 32 个文件 + `src/app/(dashboard)/` 下 11 个相关路由分组
|
||||
> 架构图依据:`docs/architecture/004_architecture_impact_map.md` §2.7、`docs/architecture/005_architecture_data.json` modules.classes 节点
|
||||
> 审计基线:项目规则(三层架构、权限校验、i18n、TypeScript 严格模式、文件行数、可测试性)
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
classes 模块位于 `src/modules/classes/`,共 **32 个文件**:
|
||||
|
||||
| 层 | 文件数 | 关键文件 |
|
||||
|---|---|---|
|
||||
| Server Actions | 7 | `actions.ts`(barrel)+ `actions-admin.ts` / `actions-grade.ts` / `actions-teacher.ts` / `actions-invitations.ts` / `actions-schedule.ts` / `actions-shared.ts` |
|
||||
| Data-access | 7 | `data-access.ts`(聚合)+ `data-access-admin.ts` / `data-access-teacher.ts` / `data-access-students.ts` / `data-access-stats.ts` / `data-access-schedule.ts` / `data-access-invitations.ts` |
|
||||
| Schema/Types | 2 | `schema.ts`(13 个 Zod schema)、`types.ts`(19 个类型) |
|
||||
| Components | 21 | `admin-classes-view.tsx` / `grade-classes-view.tsx` / `class-list-table.tsx` / `class-form-dialog.tsx` / `class-delete-dialog.tsx` / `class-list-toolbar.tsx` / `class-form-utils.ts` / `class-error-boundary.tsx` / `class-invitation-manager.tsx` / `class-skeleton.tsx` / `my-classes-grid.tsx` / `schedule-view.tsx` / `schedule-filters.tsx` / `students-filters.tsx` / `students-table.tsx` + `class-detail/` 下 7 个 widget(header/overview-stats/quick-actions/schedule-widget/students-widget/assignments-widget/trends-widget)+ `class-detail/edit-class-dialog.tsx` |
|
||||
| Hooks | 2 | `use-class-data.ts`、`use-class-filters.ts` |
|
||||
|
||||
### 1.2 主要数据流
|
||||
|
||||
```
|
||||
admin/school/classes ─┐
|
||||
management/grade/classes ─┤ ─▶ actions-admin / actions-grade
|
||||
│ │
|
||||
teacher/classes/my ─┤ ▼
|
||||
teacher/classes/schedule ─┤ data-access-admin / data-access-teacher
|
||||
teacher/classes/students ─┤ │
|
||||
│ ▼ (跨模块通过对方 data-access)
|
||||
student/learning/courses ─┤ homework/data-access-classes(作业统计)
|
||||
student/schedule ─┤ scheduling/data-access-class-schedule(课表写)
|
||||
parent/children/[id] ─┘ school/data-access(年级管理权限校验)
|
||||
```
|
||||
|
||||
### 1.3 架构图覆盖完整性评估
|
||||
|
||||
| 维度 | 004 文档 | 005 JSON | 一致性 |
|
||||
|---|---|---|---|
|
||||
| 模块职责 | ✅ §2.7 | ✅ modules.classes.description | 一致 |
|
||||
| 依赖关系(出向) | ✅ shared/auth/school/homework/scheduling | ✅ dependencyMatrix 5 条边 | 一致 |
|
||||
| 被依赖关系(入向) | ⚠️ 列出 10 个,遗漏 elective/error-book/adaptive-practice | ✅ 10 条边覆盖完整 | **不一致** |
|
||||
| 导出函数 | ✅ 17 actions + 33 data-access | ⚠️ exports.actions 漏 3 个邀请码 action;exports.dataAccess 漏 6 个工具函数 | **不一致** |
|
||||
| 数据库表 | ✅ classes/classSubjectTeachers/classEnrollments/classInvitationCodes | ⚠️ classInvitationCodes 字段未详细登记;classSchedule 的 usedBy 字段未含 scheduling | **部分遗漏** |
|
||||
| 权限点 | ✅ 6 个 CLASS_* 权限 | ✅ permissions 常量定义完整 | 一致 |
|
||||
| 路由 | ✅ 11 条路由登记 | ✅ routes 节点登记 | 一致 |
|
||||
| 文件清单 | ✅ 33 个文件 | ⚠️ modules.classes.files 仅列 21 个,漏 12 个组件文件 | **不一致** |
|
||||
|
||||
**结论**:架构图总体覆盖较完整,但存在 7 处需同步更新(详见第五章)。
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 三层架构合规性
|
||||
|
||||
#### 问题 A1:data-access.ts 与拆分文件 3 对同名函数重复定义(🔴 P0)
|
||||
|
||||
- **位置**:
|
||||
- [data-access.ts:400](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L400) `getTeacherScopeData` ↔ [data-access-teacher.ts:592](file:///e:/Desktop/CICD/src/modules/classes/data-access-teacher.ts#L592)
|
||||
- [data-access.ts:428](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L428) `getStudentScopeData` ↔ [data-access-students.ts:309](file:///e:/Desktop/CICD/src/modules/classes/data-access-students.ts#L309)
|
||||
- [data-access.ts:455](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L455) `getGradeIdsForStudentIds` ↔ [data-access-students.ts:336](file:///e:/Desktop/CICD/src/modules/classes/data-access-students.ts#L336)
|
||||
- **现状**:`data-access.ts:383-386` 同时 `export * from "./data-access-*"` 与本地 `export const`,ES 模块语义下本地定义优先,子文件同名导出对聚合入口而言是死代码。
|
||||
- **违反规则**:架构分层规则「data-access 拆分应避免职责重叠」+ DRY 原则。
|
||||
- **后果**:两份实现目前逻辑一致,但任何一方修改都不会自动同步。若消费者直接 `import { getStudentScopeData } from "@/modules/classes/data-access-students"`,会得到另一份实现,是高风险维护陷阱。
|
||||
|
||||
#### 问题 A2:组件使用绝对路径导入本模块 actions(🟢 P2)
|
||||
|
||||
- **位置**:[class-invitation-manager.tsx:30-32](file:///e:/Desktop/CICD/src/modules/classes/components/class-invitation-manager.tsx#L30)
|
||||
- **现状**:`import { createClassInvitationCodeAction } from "@/modules/classes/actions"`,而 `my-classes-grid.tsx`、`admin-classes-view.tsx` 等同模块其他组件使用 `"../actions"` 相对路径。
|
||||
- **违反规则**:编码规范一致性。
|
||||
- **后果**:模块迁移/重命名成本上升。
|
||||
|
||||
### 2.2 权限校验
|
||||
|
||||
#### 问题 B1:listClassInvitationCodesAction 无班级归属校验(🔴 P0 越权漏洞)
|
||||
|
||||
- **位置**:[actions-invitations.ts:312-351](file:///e:/Desktop/CICD/src/modules/classes/actions-invitations.ts#L312)
|
||||
- **现状**:
|
||||
```ts
|
||||
export async function listClassInvitationCodesAction(classId: string) {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
// ❌ 任何拥有 CLASS_ENROLL 权限的用户都可以列出任意 classId 的所有邀请码
|
||||
const codes = await listClassInvitationCodes(classId)
|
||||
}
|
||||
```
|
||||
- **违反规则**:安全规范「data-access 查询是否结合当前用户权限过滤(防越权)」+ Server Action 规范。
|
||||
- **后果**:教师 A 可通过传入教师 B 的 classId 枚举其邀请码(含 code 字符串),可能引发越权获取加入凭证,破坏邀请码体系的安全性。
|
||||
|
||||
#### 问题 B2:3 个 schedule action 无班级归属校验(🔴 P0 越权漏洞)
|
||||
|
||||
- **位置**:[actions-schedule.ts:21-59](file:///e:/Desktop/CICD/src/modules/classes/actions-schedule.ts#L21)(create)、[61-101](file:///e:/Desktop/CICD/src/modules/classes/actions-schedule.ts#L61)(update)、[103-122](file:///e:/Desktop/CICD/src/modules/classes/actions-schedule.ts#L103)
|
||||
- **现状**:3 个 action 仅调用 `requirePermission(CLASS_SCHEDULE)` 后直接调用 scheduling 模块 data-access,未校验当前用户对 `classId` 的归属。
|
||||
- **违反规则**:安全规范越权防护。
|
||||
- **后果**:教师 A 可为教师 B 的班级添加/修改/删除课表项,破坏排课数据完整性。
|
||||
|
||||
#### 问题 B3:教师 update/delete/enroll action 未在 actions 层做归属校验(🟡 P1)
|
||||
|
||||
- **位置**:[actions-teacher.ts:79-122](file:///e:/Desktop/CICD/src/modules/classes/actions-teacher.ts#L79)(update)、[125-146](file:///e:/Desktop/CICD/src/modules/classes/actions-teacher.ts#L125)(delete)、[actions-invitations.ts:21-49](file:///e:/Desktop/CICD/src/modules/classes/actions-invitations.ts#L21)、[353-377](file:///e:/Desktop/CICD/src/modules/classes/actions-invitations.ts#L353)、[383-440](file:///e:/Desktop/CICD/src/modules/classes/actions-invitations.ts#L383)
|
||||
- **现状**:actions 层仅 `requirePermission`,依赖 data-access 内部 `getTeacherIdForMutations()` + `eq(classes.teacherId, teacherId)` 校验。
|
||||
- **违反规则**:Server Action 规范「权限校验应在 actions 层完成」。
|
||||
- **后果**:管理员(拥有 CLASS_UPDATE 权限)调用时,因 data-access 内部强制按 teacherId 过滤而失败,错误信息为 "Teacher not found" 不友好;如未来重构 data-access 暴露 admin 路径,归属校验会被跳过。
|
||||
|
||||
#### 问题 B4:data-access 中 7 处硬编码角色名字符串(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- [data-access.ts:26](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L26) `eq(roles.name, "teacher")`
|
||||
- [data-access-admin.ts:346](file:///e:/Desktop/CICD/src/modules/classes/data-access-admin.ts#L346)、[425](file:///e:/Desktop/CICD/src/modules/classes/data-access-admin.ts#L425)
|
||||
- [data-access-teacher.ts:125](file:///e:/Desktop/CICD/src/modules/classes/data-access-teacher.ts#L125)、[308](file:///e:/Desktop/CICD/src/modules/classes/data-access-teacher.ts#L308)、[472](file:///e:/Desktop/CICD/src/modules/classes/data-access-teacher.ts#L472)、[545](file:///e:/Desktop/CICD/src/modules/classes/data-access-teacher.ts#L545)
|
||||
- **违反规则**:命名规范「常量:UPPER_SNAKE_CASE」+ DRY。
|
||||
- **后果**:若角色名变更(中英切换、复数化),需修改 7 处;魔法字符串降低可读性。
|
||||
|
||||
#### 问题 B5:student 三个页面未调用 requirePermission 显式声明权限点(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- [student/learning/courses/page.tsx:19](file:///e:/Desktop/CICD/src/app/(dashboard)/student/learning/courses/page.tsx#L19)
|
||||
- [student/learning/courses/[classId]/page.tsx:40-41](file:///e:/Desktop/CICD/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx#L40)
|
||||
- [student/schedule/page.tsx:19](file:///e:/Desktop/CICD/src/app/(dashboard)/student/schedule/page.tsx#L19)
|
||||
- **现状**:仅 `getCurrentStudentUser()` 软校验学生身份,未显式声明所需权限点;`[classId]` 页面在权限不足时返回 `notFound()`,错误语义混淆(404 vs 403)。
|
||||
- **违反规则**:Server Action 必须使用 `requirePermission()`。
|
||||
- **后果**:权限审计与文档化困难;404/403 错误语义混淆影响用户体验与监控告警。
|
||||
|
||||
### 2.3 国际化(i18n)
|
||||
|
||||
#### 问题 C1:class-detail/ 子组件普遍硬编码英文(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- [class-assignments-widget.tsx:40,46,64,84,88,101](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-assignments-widget.tsx#L40)
|
||||
- [class-overview-stats.tsx:28,33,39,45](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-overview-stats.tsx#L28)
|
||||
- [class-quick-actions.tsx:26,32,36](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-quick-actions.tsx#L26)
|
||||
- [class-schedule-widget.tsx:17,102,110](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-schedule-widget.tsx#L17)
|
||||
- [class-students-widget.tsx:36,41](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-students-widget.tsx#L36)
|
||||
- [class-trends-widget.tsx:41-55,145,164,174,182,188,282,288,301,392](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-trends-widget.tsx#L41)
|
||||
- **现状**:8 个详情子组件几乎全部使用硬编码英文字符串。
|
||||
- **违反规则**:i18n 强制要求 + 架构图 004:998 已声明"13 个组件全部接入 i18n"——与现状不一致。
|
||||
- **后果**:详情页完全无法中文化,与 K12 中文产品定位严重冲突;架构图存在错误声明。
|
||||
|
||||
#### 问题 C2:schedule-view / schedule-filters / students-filters 硬编码英文(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- [schedule-view.tsx:111,117,165,166,180,192,310,343,348,357,369,386,435,448,469,489,505-508](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-view.tsx#L111)
|
||||
- [schedule-filters.tsx:111,117,164,171,180,192](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-filters.tsx#L111)
|
||||
- [students-filters.tsx:81,110,141,169,173,174,197,204,211](file:///e:/Desktop/CICD/src/modules/classes/components/students-filters.tsx#L81)
|
||||
- **违反规则**:i18n 强制要求。
|
||||
- **后果**:教师端课表/学生管理页全部英文,破坏产品一致性。
|
||||
|
||||
#### 问题 C3:所有 actions 返回的 message 为英文硬编码(🟡 P1)
|
||||
|
||||
- **位置**:全部 7 个 actions 文件
|
||||
- **示例**:[actions-admin.ts:39](file:///e:/Desktop/CICD/src/modules/classes/actions-admin.ts#L39) `"Class name, grade and teacher are required"` / [actions-admin.ts:59](file:///e:/Desktop/CICD/src/modules/classes/actions-admin.ts#L59) `"Class created successfully"` / [actions-invitations.ts:432](file:///e:/Desktop/CICD/src/modules/classes/actions-invitations.ts#L432) `` `Imported ${imported} students, ${failed} failed` ``
|
||||
- **现状**:组件层 `toast.success(res.message)` 直接显示后端字符串。
|
||||
- **违反规则**:i18n 强制要求。
|
||||
- **后果**:中文用户看到全英文错误提示,体验差。
|
||||
|
||||
#### 问题 C4:30+ 个 error.tsx 使用硬编码中文文案(🟡 P1)
|
||||
|
||||
- **位置**:`src/app/(dashboard)/` 下 30 处 error.tsx(含 `admin/school/classes/error.tsx`、`management/grade/classes/error.tsx` 等)
|
||||
- **示例**:[admin/school/classes/error.tsx:12-16](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/classes/error.tsx#L12) `title="页面加载失败"` / `description="抱歉,页面加载时发生了意外错误。请稍后重试。"`
|
||||
- **违反规则**:i18n 强制要求。
|
||||
- **后果**:英文用户在错误页仍看到中文,破坏语言一致性;30+ 处重复字符串维护成本高。
|
||||
|
||||
#### 问题 C5:DEFAULT_CLASS_SUBJECTS 与 excludeSubjects 业务常量硬编码中文(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- [types.ts:46](file:///e:/Desktop/CICD/src/modules/classes/types.ts#L46) `export const DEFAULT_CLASS_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const`
|
||||
- [data-access-students.ts:41](file:///e:/Desktop/CICD/src/modules/classes/data-access-students.ts#L41) `const excludeSubjects = ["体育", "音乐", "美术"]`
|
||||
- **违反规则**:DRY(两份科目清单)+ i18n(业务规则硬编码字符串)。
|
||||
- **后果**:与 `subjects` 表查询重复;不同学校配置无法适配;`excludeSubjects` 未在架构图记录。
|
||||
|
||||
#### 问题 C6:getSubjectColor 用英文匹配中文科目(🔴 P0 功能 bug)
|
||||
|
||||
- **位置**:[schedule-view.tsx:186-195](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-view.tsx#L186)
|
||||
- **现状**:
|
||||
```ts
|
||||
const getSubjectColor = (subject: string) => {
|
||||
const s = subject.toLowerCase()
|
||||
if (s.includes('math')) return 'bg-blue-500/10 ...'
|
||||
if (s.includes('physics') || s.includes('science')) return '...'
|
||||
if (s.includes('english') || s.includes('lit')) return '...'
|
||||
```
|
||||
- **问题**:`DEFAULT_CLASS_SUBJECTS` 是中文("数学"、"英语"等),但 `getSubjectColor` 用英文 'math'/'english' 匹配,所有中文科目都会落到 default 分支,颜色视觉分组完全失效。
|
||||
- **违反规则**:i18n + 功能正确性。
|
||||
- **后果**:课表颜色视觉分组完全失效,不仅是 i18n 问题。
|
||||
|
||||
### 2.4 错误处理
|
||||
|
||||
#### 问题 D1:catch 块未记录错误,仅显示 toast(🟡 P1)
|
||||
|
||||
- **位置**:10 处
|
||||
- [my-classes-grid.tsx:75-77](file:///e:/Desktop/CICD/src/modules/classes/components/my-classes-grid.tsx#L75)
|
||||
- [schedule-filters.tsx:65-67](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-filters.tsx#L65)
|
||||
- [schedule-view.tsx:113-115](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-view.tsx#L113)
|
||||
- [students-filters.tsx:73-75](file:///e:/Desktop/CICD/src/modules/classes/components/students-filters.tsx#L73)
|
||||
- [admin-classes-view.tsx:52-54](file:///e:/Desktop/CICD/src/modules/classes/components/admin-classes-view.tsx#L52)
|
||||
- [grade-classes-view.tsx:42-44](file:///e:/Desktop/CICD/src/modules/classes/components/grade-classes-view.tsx#L42)
|
||||
- [class-invitation-manager.tsx:101-103](file:///e:/Desktop/CICD/src/modules/classes/components/class-invitation-manager.tsx#L101)、[263-265](file:///e:/Desktop/CICD/src/modules/classes/components/class-invitation-manager.tsx#L263)
|
||||
- [edit-class-dialog.tsx:55-57](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/edit-class-dialog.tsx#L55)
|
||||
- [students-table.tsx:41-43](file:///e:/Desktop/CICD/src/modules/classes/components/students-table.tsx#L41)
|
||||
- **现状**:`} catch { toast.error(t("list.failedCreate")) }`
|
||||
- **违反规则**:错误处理最佳实践。
|
||||
- **后果**:服务端 500、网络错误、权限错误全部显示同一文案,开发者无法从用户截图定位错误;线上排查困难。
|
||||
|
||||
#### 问题 D2:data-access 内 catch 静默返回空数组(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- [data-access-admin.ts:88-124](file:///e:/Desktop/CICD/src/modules/classes/data-access-admin.ts#L88)(getAdminClasses fallback 合理降级)
|
||||
- [data-access-students.ts:159-177](file:///e:/Desktop/CICD/src/modules/classes/data-access-students.ts#L159)(getStudentClasses fallback 合理降级)
|
||||
- [data-access-teacher.ts:70-73](file:///e:/Desktop/CICD/src/modules/classes/data-access-teacher.ts#L70)(getTeacherClasses 直接返回 `[]`)
|
||||
- **问题**:`getTeacherClasses` 失败时返回空数组,会让教师看到"无班级"假象,区分不出"数据库错误"与"无数据"。
|
||||
- **违反规则**:错误处理最佳实践。
|
||||
- **后果**:教师班级列表假性空数据,难以排查。
|
||||
|
||||
#### 问题 D3:actions 层错误处理两套风格混用(🟡 P1)
|
||||
|
||||
- **位置**:
|
||||
- 风格 A(手动 try/catch + `PermissionDeniedError`):[actions-admin.ts:63-66](file:///e:/Desktop/CICD/src/modules/classes/actions-admin.ts#L63)、[actions-grade.ts](file:///e:/Desktop/CICD/src/modules/classes/actions-grade.ts)、[actions-invitations.ts](file:///e:/Desktop/CICD/src/modules/classes/actions-invitations.ts)
|
||||
- 风格 B(`handleActionError`):[actions-teacher.ts:74-76](file:///e:/Desktop/CICD/src/modules/classes/actions-teacher.ts#L74)、[actions-schedule.ts](file:///e:/Desktop/CICD/src/modules/classes/actions-schedule.ts)
|
||||
- **违反规则**:编码规范一致性。
|
||||
- **后果**:权限拒绝场景下风格 A 会 `throw e` 冒泡到 Next.js 错误边界(用户体验差),风格 B 会转为结构化失败;维护成本高。
|
||||
|
||||
#### 问题 D4:teacher/classes/my/[id] 缺 loading.tsx 和 error.tsx(🟡 P1)
|
||||
|
||||
- **位置**:`src/app/(dashboard)/teacher/classes/my/[id]/`
|
||||
- **现状**:页面内部 4 个 `Promise.all` 并行数据获取,但完全没有 loading.tsx 和 error.tsx。
|
||||
- **违反规则**:项目规则「学生路由必须包含 loading.tsx 和 error.tsx」+ 企业级规范。
|
||||
- **后果**:无骨架屏感知性能差;任一 data-access 抛错冒泡至上层;`notFound()` 触发时展示默认 404 与站点风格不一致。
|
||||
|
||||
#### 问题 D5:10 个页面缺 error.tsx(🟡 P1)
|
||||
|
||||
- **位置**:teacher/classes/my、teacher/classes/schedule、teacher/classes/students、student/learning/courses、student/learning/courses/[classId]、student/schedule 等
|
||||
- **违反规则**:企业级规范要求主要路由必须有 error.tsx。
|
||||
- **后果**:错误上下文丢失,错误冒泡至上层。
|
||||
|
||||
### 2.5 类型安全
|
||||
|
||||
#### 问题 E1:formData.get 强转 string 应使用类型守卫(🟢 P2)
|
||||
|
||||
- **位置**:[actions-admin.ts:93](file:///e:/Desktop/CICD/src/modules/classes/actions-admin.ts#L93)、[actions-grade.ts:98](file:///e:/Desktop/CICD/src/modules/classes/actions-grade.ts#L98)
|
||||
- **现状**:`formData.get("subjectTeachers") as string | null`
|
||||
- **违反规则**:禁止 `as` 断言(除类型收窄外)。
|
||||
- **后果**:若前端意外提交 `File` 对象(如通过 FormData.append 上传文件),`parseSubjectTeachers` 会因 `typeof raw !== "string"` 返回 null 而静默丢弃数据。
|
||||
|
||||
#### 问题 E2:data-access-invitations.ts 中两处 string → union 强转(🟢 P2)
|
||||
|
||||
- **位置**:[data-access-invitations.ts:274](file:///e:/Desktop/CICD/src/modules/classes/data-access-invitations.ts#L274)、[363](file:///e:/Desktop/CICD/src/modules/classes/data-access-invitations.ts#L363)
|
||||
- **现状**:`record.status as ValidationResult["reason"]` / `status: row.status as InvitationCodeStatus`
|
||||
- **违反规则**:禁止 `as` 断言。
|
||||
- **后果**:DB 中出现意外值(如 "pending")不会触发类型错误,运行时返回错误 reason。
|
||||
|
||||
#### 问题 E3:class-trends-widget.tsx 中 as string[] 应使用类型守卫(🟢 P2)
|
||||
|
||||
- **位置**:[class-trends-widget.tsx:129](file:///e:/Desktop/CICD/src/modules/classes/components/class-detail/class-trends-widget.tsx#L129)
|
||||
- **现状**:`Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]`
|
||||
- **违反规则**:禁止 `as` 断言。
|
||||
- **后果**:`filter(Boolean)` 在 TypeScript 中不会收窄类型,跳过空值检查。
|
||||
|
||||
#### 问题 E4:14 个组件事件处理函数缺 Promise<void> 返回类型(🟢 P2)
|
||||
|
||||
- **位置**:my-classes-grid.tsx(4 处)、schedule-filters.tsx、schedule-view.tsx(4 处)、students-filters.tsx、students-table.tsx、class-invitation-manager.tsx(3 处)、edit-class-dialog.tsx
|
||||
- **违反规则**:函数返回值必须显式标注,特别是 `Promise<T>`。
|
||||
- **后果**:若将来函数内部 `return` 一个值(如返回 boolean 表示是否成功),调用方无类型提示。
|
||||
|
||||
### 2.6 文件大小
|
||||
|
||||
#### 问题 F1:schedule-view.tsx 527 行超出组件 500 行建议(🟡 P1)
|
||||
|
||||
- **位置**:[schedule-view.tsx](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-view.tsx)
|
||||
- **现状**:同时承担"周历视图渲染 + 创建对话框 + 编辑对话框 + 删除确认对话框"4 个职责。
|
||||
- **违反规则**:React 组件 ≤ 500 行。
|
||||
- **后果**:可读性差、修改易引入回归。
|
||||
|
||||
#### 问题 F2:my-classes-grid.tsx 内含 210 行巨型组件 ClassTicket(🟢 P2)
|
||||
|
||||
- **位置**:[my-classes-grid.tsx:208-417](file:///e:/Desktop/CICD/src/modules/classes/components/my-classes-grid.tsx#L208)
|
||||
- **现状**:单一组件管理 4 段视觉职责(票据左侧信息+邀请码+趋势图+周历嵌入式渲染)。
|
||||
- **违反规则**:组件规范。
|
||||
- **后果**:调试困难。
|
||||
|
||||
### 2.7 组件复用性
|
||||
|
||||
#### 问题 G1:3 个 view 的 CRUD handler 几乎重复(🟢 P2)
|
||||
|
||||
- **位置**:admin-classes-view.tsx:41-95、grade-classes-view.tsx:31-85、schedule-view.tsx:100-158
|
||||
- **现状**:相同的 try/catch + toast + router.refresh 模式重复 9 次(每个 view 3 个 handler)。
|
||||
- **违反规则**:DRY。
|
||||
- **后果**:错误处理改进(如 D1 加 console.error)需修改 9 处。
|
||||
|
||||
### 2.8 可测试性
|
||||
|
||||
#### 问题 H1:纯函数内嵌组件未导出(🟢 P2)
|
||||
|
||||
- **位置**:
|
||||
- [my-classes-grid.tsx:40-48](file:///e:/Desktop/CICD/src/modules/classes/components/my-classes-grid.tsx#L40) `getSeededValue`
|
||||
- [my-classes-grid.tsx:268-272](file:///e:/Desktop/CICD/src/modules/classes/components/my-classes-grid.tsx#L268) `performanceChange` 计算
|
||||
- [schedule-view.tsx:160-181](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-view.tsx#L160) `getPositionStyle`
|
||||
- [schedule-view.tsx:186-195](file:///e:/Desktop/CICD/src/modules/classes/components/schedule-view.tsx#L186) `getSubjectColor`
|
||||
- **违反规则**:可测试性「纯逻辑是否与 UI 分离」。
|
||||
- **后果**:业务计算逻辑(环比变化率、课表块定位、颜色映射)无法独立单测;`getSubjectColor` 还存在 C6 提到的功能 bug,但因内嵌而难以被发现。
|
||||
|
||||
### 2.9 i18n metadata 缺失
|
||||
|
||||
#### 问题 I1:8 个 teacher/student 页面缺 generateMetadata(🟢 P2)
|
||||
|
||||
- **位置**:teacher/classes/my、teacher/classes/my/[id]、teacher/classes/schedule、teacher/classes/students、student/learning/courses、student/learning/courses/[classId]、student/schedule、parent/children/[studentId]
|
||||
- **违反规则**:页面级 metadata.title 应走 i18n。
|
||||
- **后果**:浏览器标签栏、社交分享卡片等场景文案不本地化,SEO 友好度差。
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
参考 Google Classroom、钉钉教育、智学网、ClassDojo、PowerSchool 等 K12 班级管理产品的主流设计模式,对比当前实现差距:
|
||||
|
||||
### 3.1 班级列表与详情
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 班级卡片信息密度 | Google Classroom 卡片含教师头像、学生数、最近活动时间、未读作业数 | `my-classes-grid.tsx` 含邀请码、提交率趋势、周历嵌入,信息密度高但**未提供"最近活动"时间线** | 缺少"班级最近动态"feed |
|
||||
| 班级封面图 | Google Classroom / ClassDojo 支持自定义班级主题图 | 仅色彩区分 | 视觉识别度弱 |
|
||||
| 班级详情布局 | 智学网采用"Tab 切换(学生/作业/成绩/课表/设置)+ 顶部 sticky header" | `class-detail/` 采用 widget 网格布局 | widget 平铺在班级数多时滚动疲劳;可考虑 Tab 化 |
|
||||
| 班级归档 | Google Classroom 支持"归档班级",归档后只读但保留数据 | 无归档功能 | 学年结束后历史班级污染列表 |
|
||||
| 班级复制 | Google Classroom 支持复制班级(含学生/科目配置) | 无 | 新学年建班成本高 |
|
||||
|
||||
### 3.2 学生管理
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 学生加入方式 | 邀请码 + 邮件 + 批量导入 + 班级链接 | 已实现邀请码 + 邮件 + 批量导入 | 缺少"班级加入链接"(点击即加入) |
|
||||
| 学生列表筛选 | 智学网支持按科目成绩、出勤率、活跃度多维筛选 | `students-filters.tsx` 仅按班级 + 状态 | 缺少按学业表现筛选 |
|
||||
| 学生卡片信息 | Google Classroom 显示学生头像、最近提交、整体进度 | `students-table.tsx` 显示头像+科目成绩 | 缺少"最近提交/整体进度"时间维度 |
|
||||
| 学生迁移 | 钉钉教育支持"批量迁班"(学年升级时) | 无 | 学年升级时手动逐个调整 |
|
||||
| 学生邀请码状态可视化 | ClassDojo 显示邀请码扫描情况(已加入/待加入) | `class-invitation-manager.tsx` 显示邀请码列表 | 缺少"已扫码未加入"中间状态 |
|
||||
|
||||
### 3.3 课表
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 课表视图 | 智学网周课表 + 日课表 + 月课表三视图切换 | `schedule-view.tsx` 仅周课表 | 缺少日/月视图 |
|
||||
| 课表冲突检测 | PowerSchool 在添加时自动检测教室/教师/时段冲突 | 依赖 scheduling 模块外部检测 | 当前 actions-schedule 未触发检测(B2) |
|
||||
| 课表颜色编码 | 钉钉教育按科目自动配色 | `getSubjectColor` 中文失效(C6) | **功能 bug,必须修复** |
|
||||
| 课表导出/打印 | 智学网支持导出 PDF/Excel、打印 | 无 | 教师打印课表需求未满足 |
|
||||
| 课表提醒 | Google Classroom 课前 5 分钟推送提醒 | 无 | 缺少课表提醒集成 |
|
||||
|
||||
### 3.4 多角色协作
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 家长视角班级信息 | ClassDojo 家长看到班级公告、教师动态、孩子表现 | parent 仅看到孩子课表 + 班级概览 | 缺少"班级动态 feed" |
|
||||
| 学生视角班级首页 | Google Classroom 学生首页是"待办作业流" | student/learning/courses 是班级列表 | 缺少"班级作业待办流"聚合视图 |
|
||||
| 跨班级协作 | 钉钉教育支持"年级主任一键查看所有班级对比" | management/grade 已有 insights | ✅ 已实现,对比图较完善 |
|
||||
| 班级消息 | Google Classroom 班级内消息流 | messaging 模块支持 `class_members` scope | ✅ 已通过 messaging 模块实现 |
|
||||
|
||||
### 3.5 数据洞察
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 班级健康度评分 | PowerSchool 综合出勤+成绩+参与度给出班级健康分 | `class-trends-widget.tsx` 仅展示提交率/平均分 | 缺少综合健康度评分 |
|
||||
| 早期预警 | ClassDojo 识别"低参与度学生"自动预警 | 无 | 缺少学生风险预警 |
|
||||
| 班级对比 | 智学网支持同年级班级多维度对比 | `management/grade/insights` 已有 | ✅ |
|
||||
| 趋势同比环比 | PowerSchool 提供周/月/学期同比 | `class-trends-widget.tsx` 仅"Latest"指标 | 缺少时间维度对比 |
|
||||
|
||||
### 3.6 可访问性与性能
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 键盘导航 | WCAG 2.1 AA 要求所有交互可键盘操作 | 班级列表/课表键盘可访问但无 focus-visible 样式优化 | a11y 待加强 |
|
||||
| 流式渲染 | React 18+ Suspense 流式渲染 | 全部 RSC 同步获取,无 Suspense 边界 | 班级详情 4 个并行查询可流式 |
|
||||
| 骨架屏精确度 | 骨架屏应反映实际布局 | `class-skeleton.tsx` 5 个 skeleton 布局匹配 | ✅ 良好 |
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 安全/功能阻断(立即修复)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|---|---|
|
||||
| P0-1 | A1:data-access.ts 与拆分文件 3 对同名函数重复定义 | 删除 data-access.ts 中的本地定义,统一从拆分文件导出(保持 barrel 入口兼容) |
|
||||
| P0-2 | B1:listClassInvitationCodesAction 无归属校验 | 在 actions 层调用 `verifyTeacherOwnsClass(classId, ctx.userId)`(admin scope 跳过) |
|
||||
| P0-3 | B2:3 个 schedule action 无归属校验 | 同 P0-2,调用 `verifyTeacherOwnsClass` |
|
||||
| P0-4 | C6:getSubjectColor 中文科目不匹配 | 改用科目 ID 或类型守卫匹配;纯函数抽出到 `schedule-utils.ts` 并补单测 |
|
||||
| P0-5 | B3:教师 update/delete/enroll action 未在 actions 层做归属校验 | actions 层显式判断 `hasAdminScope(ctx)` 否则校验 `classes.teacherId === ctx.userId` |
|
||||
|
||||
### P1 — 重要合规性(本期实施)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|---|---|
|
||||
| P1-1 | C1:class-detail/ 8 子组件硬编码英文 | 全部接入 `useTranslations("classes.detail.*")` |
|
||||
| P1-2 | C2:schedule-view / schedule-filters / students-filters 硬编码英文 | 接入 i18n |
|
||||
| P1-3 | C3:所有 actions message 英文硬编码 | 在 actions 层使用 `getTranslations()` 或返回错误码由组件层翻译 |
|
||||
| P1-4 | C4:30+ error.tsx 硬编码中文 | 抽取 `shared/components/ErrorState` + i18n key `common.error.boundary.*` |
|
||||
| P1-5 | C5:DEFAULT_CLASS_SUBJECTS 与 excludeSubjects 硬编码 | 改为从 `subjects` 表查询;统一单一来源 |
|
||||
| P1-6 | B4:data-access 中 7 处 roles.name 硬编码 | 抽出 `ROLE_TEACHER` 常量 |
|
||||
| P1-7 | B5:student 三个页面未调用 requirePermission | 补 `requirePermission(Permissions.HOMEWORK_SUBMIT)` 或新增 STUDENT_READ 权限 |
|
||||
| P1-8 | D1:10 处 catch 块未记录错误 | 改为 `catch (error) { console.error("[classes] xxx:", error); toast.error(...) }` |
|
||||
| P1-9 | D2:getTeacherClasses 静默返回空数组 | 区分"DB 错误"与"无数据",DB 错误抛出 |
|
||||
| P1-10 | D3:actions 层错误处理两套风格 | 统一为 `handleActionError` |
|
||||
| P1-11 | D4:teacher/classes/my/[id] 缺 loading.tsx 和 error.tsx | 新增 |
|
||||
| P1-12 | D5:10 个页面缺 error.tsx | 补齐 |
|
||||
| P1-13 | F1:schedule-view.tsx 527 行超限 | 抽出 3 个对话框子组件 |
|
||||
|
||||
### P2 — 工程优化(中长期)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|---|---|
|
||||
| P2-1 | E1/E2/E3:5 处 as 断言 | 改用类型守卫 |
|
||||
| P2-2 | E4:14 个事件处理函数缺 Promise<void> | 补返回类型 |
|
||||
| P2-3 | G1:3 个 view CRUD handler 重复 | 抽 `useClassFormHandlers` hook |
|
||||
| P2-4 | H1:纯函数内嵌组件 | 抽到 `class-stats-utils.ts` / `schedule-utils.ts` 并补单测 |
|
||||
| P2-5 | F2:my-classes-grid ClassTicket 210 行 | 拆分为 `ClassTicketHeader` / `ClassTicketInvitation` / `ClassTicketTrend` |
|
||||
| P2-6 | A2:class-invitation-manager 绝对路径 | 改为相对路径 |
|
||||
| P2-7 | I1:8 个页面缺 generateMetadata | 补齐 |
|
||||
| P2-8 | 行业差距:班级归档/复制 | 中长期产品规划 |
|
||||
| P2-9 | 行业差距:班级健康度评分/早期预警 | 中长期产品规划 |
|
||||
| P2-10 | 行业差距:课表多视图/导出/提醒 | 中长期产品规划 |
|
||||
|
||||
### 重构设计原则(强制满足)
|
||||
|
||||
为达成"完全解耦 + 组合优先 + 国际化就绪 + 最大化复用 + 错误边界 + 可测试 + 可扩展 + 企业级补充"八项原则,本次实施遵循:
|
||||
|
||||
1. **完全解耦**:classes 模块内部组件不直接 import 其他业务模块(exams/grades/homework/scheduling)的 actions/data-access;跨模块数据通过本模块 data-access 暴露的接口调用(已基本达成,仅需修复 P0-2/P0-3 的越权问题)。
|
||||
2. **组合优先**:错误处理抽 `useClassFormHandlers` hook;纯逻辑抽 `class-stats-utils.ts`、`schedule-utils.ts`;详情 widget 通过配置驱动(`ClassDetailWidgetConfig`)。
|
||||
3. **国际化就绪**:所有修复项必须使用 i18n key;新增翻译键写入 `messages/{locale}/classes.json` 的 `detail.*` / `schedule.*` / `students.*` 命名空间。
|
||||
4. **最大化复用**:admin/grade/teacher/student 共用 `ClassListTable` / `ClassFormDialog` / `ClassDeleteDialog` / `useClassData` / `useClassFilters` / `useClassFormHandlers`。
|
||||
5. **错误与边界**:每个详情 widget 独立用 `ClassErrorBoundary` 包裹;RSC 数据获取用 React Suspense + 骨架屏;空数据/无权限/网络异常状态明确处理。
|
||||
6. **可测试性**:纯函数导出至 `*.ts` 文件,便于 vitest 单测;接口类型显式标注。
|
||||
7. **可扩展性**:角色差异通过 `hasAdminScope` / `hasTeacherScope` 抽象,新增角色只改 actions-shared.ts。
|
||||
8. **企业级补充**:补 a11y(focus-visible、ARIA)、性能(Suspense 流式)、安全(actions 层归属校验)、监控(埋点接口预留 `trackClassEvent` 工具函数)。
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图存在以下遗漏或不一致,需在实施修复时同步更新:
|
||||
|
||||
| # | 位置 | 问题 | 修复动作 |
|
||||
|---|---|---|---|
|
||||
| 1 | 005 `modules.classes.files` | 仅列 21 个文件,漏 schema.ts/types.ts/12 个组件文件 | 补全至 33 个文件 |
|
||||
| 2 | 005 `modules.classes.exports.actions` | 漏 `createClassInvitationCodeAction` / `revokeClassInvitationCodesAction` / `listClassInvitationCodesAction` 3 个 v3 邀请码 action | 补全至 20 个 |
|
||||
| 3 | 005 `modules.classes.exports.dataAccess` | 漏 `compareClassLike` / `isDuplicateInvitationCodeError` / `generateUniqueInvitationCode` / `getAccessibleClassIdsForTeacher` / `getSessionTeacherId` / `getTeacherSubjectIdsForClass` 6 个工具函数 | 补全 |
|
||||
| 4 | 005 `dbTables` | `classInvitationCodes` 表字段未详细登记 | 补全字段定义 |
|
||||
| 5 | 005 `dbTables` | `classSchedule` 的 `usedBy` 字段未含 scheduling | 补充 `["classes", "scheduling"]` |
|
||||
| 6 | 005 `knownIssues.P0-1` | 仍记录原始问题状态,未标注「已修复」 | 更新 status 字段 |
|
||||
| 7 | 004 §2.7「被依赖」 | 遗漏 `elective` / `error-book` / `adaptive-practice` 三个模块 | 补全至 13 个 |
|
||||
| 8 | 005 `dependencyMatrix` | `exams → classes` 与 `homework → classes` 边未明确登记 | 补全边定义 |
|
||||
| 9 | 004 §2.7 已知问题 | P0-2/P0-3/P0-4/P0-5 等本次新增修复 | 同步新增修复记录 |
|
||||
| 10 | 004 §2.7 文件清单 | 修复后行数变化(如 schedule-view.tsx 拆分) | 更新行数 |
|
||||
262
docs/architecture/audit/course-plans-audit-report.md
Normal file
262
docs/architecture/audit/course-plans-audit-report.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# 课程计划模块审计报告
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:`src/modules/course-plans/` 全部文件 + `src/app/(dashboard)/{admin,teacher}/course-plans/` 全部页面
|
||||
> 审计依据:`e:\Desktop\CICD\.trae\rules\project_rules.md`、`docs/architecture/004_architecture_impact_map.md` §2.18、`docs/architecture/005_architecture_data.json` modules.`course-plans`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层级 | 文件数 | 主要文件(行数) |
|
||||
|------|--------|------------------|
|
||||
| types/schema | 2 | types.ts(97)、schema.ts(180) |
|
||||
| data-access | 1 | data-access.ts(425) |
|
||||
| actions | 1 | actions.ts(284) |
|
||||
| components | 5 | course-plan-list.tsx(160)、course-plan-detail.tsx(243)、course-plan-form.tsx(284)、course-plan-item-editor.tsx(248)、course-plan-progress.tsx(38) |
|
||||
| 页面 | 6 | admin(4: list/detail/create/edit)、teacher(2: list/detail) |
|
||||
| i18n | 2 | zh-CN/course-plans.json(15)、en/course-plans.json(15) |
|
||||
|
||||
文件行数均在规范范围内(组件 ≤500、actions/data-access ≤800)。
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
页面(Server Component)
|
||||
├─ admin/* → 直接调用 data-access.getCoursePlans / getCoursePlanById(无 requirePermission)
|
||||
├─ teacher/* → requirePermission(COURSE_PLAN_READ) → data-access(按 teacherId 过滤)
|
||||
└─ management/grade/dashboard → data-access.getGradeCoursePlanProgress
|
||||
↓
|
||||
动态 import classes data-access.getClassesByGradeId
|
||||
↓
|
||||
JOIN course_plans + course_plan_items
|
||||
Client Components
|
||||
├─ CoursePlanList → usePermission() → 本地筛选
|
||||
├─ CoursePlanDetail → 直接 import deleteCoursePlanAction
|
||||
├─ CoursePlanForm → 直接 import create/updateCoursePlanAction
|
||||
└─ CoursePlanItemEditor → 直接 import item CRUD actions
|
||||
```
|
||||
|
||||
### 1.3 架构图完整性
|
||||
|
||||
`docs/architecture/004_architecture_impact_map.md` §2.18 与 `005_architecture_data.json` 已记录该模块的导出函数、文件清单、依赖关系,与实际代码**基本一致**。但存在以下遗漏与不一致:
|
||||
|
||||
- `data-access.ts` 中 `getSubjectOptions` 函数**未在架构图 exports 中记录**
|
||||
- `data-access.ts` 中 `reorderCoursePlanItems` 函数**未在架构图 exports 中记录**(且无对应 Action / UI,属于死代码)
|
||||
- 架构图标注 `getCoursePlansAction`/`getCoursePlanAction` 的 `usedBy` 为"待扩展",实际仍无消费方
|
||||
- 架构图依赖矩阵显示 course-plans → classes/school 为"✅"(通过 data-access),但实际 `buildPlanSelect` **直接 JOIN** classes/subjects/users 表,并非通过 data-access 调用——架构图记录与实现不一致
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 安全与权限问题(P0)
|
||||
|
||||
#### 问题 1:教师详情页未校验计划归属 — 信息泄露漏洞
|
||||
|
||||
- **位置**:`src/app/(dashboard)/teacher/course-plans/[id]/page.tsx` 第 16-18 行;`data-access.ts` `getCoursePlanById` 第 168-192 行
|
||||
- **问题**:教师详情页仅调用 `requirePermission(COURSE_PLAN_READ)` 后直接 `getCoursePlanById(id)`,**未校验该计划是否属于当前教师**。`getCoursePlanById` 也不接受 `userId` 参数。
|
||||
- **违反规则**:项目规则 "Parent routes must include permission checks with both parentId and studentId to prevent information leakage"(同理,教师路由也应校验 teacherId 归属);"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤"
|
||||
- **后果**:任何持有 `COURSE_PLAN_READ` 权限的教师,通过枚举/猜测 planId 即可查看全校所有课程计划详情(含其他班级、其他科目的教学进度、大纲、目标),构成信息泄露
|
||||
|
||||
#### 问题 2:admin 列表页无 requirePermission 调用
|
||||
|
||||
- **位置**:`src/app/(dashboard)/admin/course-plans/page.tsx` 全文(第 23-49 行)
|
||||
- **问题**:admin 列表页**未调用 `requirePermission()`**,直接调用 `getCoursePlans()` 返回全部数据。对比 `teacher/course-plans/page.tsx` 第 27 行有 `requirePermission` 调用——admin 与 teacher 页面权限处理不一致。
|
||||
- **违反规则**:项目规则 "所有 Server Action 必须调用 requirePermission() 进行权限校验"(页面层虽非 Action,但 data-access 直接被 Server Component 调用时同样需校验);依赖布局层保护属于隐式安全,不符合纵深防御原则
|
||||
- **后果**:若布局层权限配置被误改,admin 列表页将完全暴露
|
||||
|
||||
#### 问题 3:data-access 函数无数据范围(DataScope)过滤
|
||||
|
||||
- **位置**:`data-access.ts` `getCoursePlans`(第 144-166 行)、`getCoursePlanById`(第 168-192 行)、`getGradeCoursePlanProgress`(第 335-424 行)
|
||||
- **问题**:所有查询函数**均不接受 userId / dataScope 参数**,不进行任何归属过滤。`getCoursePlans` 仅靠调用方传入 `teacherId` 参数过滤,但参数可选且可被绕过。
|
||||
- **违反规则**:项目规则 "所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验"
|
||||
- **后果**:未来新增 parent/student 路由时,若直接复用这些函数将导致越权;当前教师详情页已暴露此问题(见问题 1)
|
||||
|
||||
### 2.2 国际化严重缺失(P0)
|
||||
|
||||
#### 问题 4:组件内大量硬编码文本,中英文混杂
|
||||
|
||||
- **位置**:
|
||||
- `course-plan-list.tsx`:第 25-47 行 `STATUS_LABEL`/`STATUS_VARIANT`/`FILTER_OPTIONS` 全英文硬编码;第 99/108-112 行 "New Course Plan"/"No course plans"/"There are no course plans yet." 等
|
||||
- `course-plan-detail.tsx`:第 30-35 行 `STATUS_LABEL` 全中文硬编码("规划中"/"进行中"/"已完成"/"已暂停");第 94/99/106-113/124-134/146-153/161-172/178-183/213 行大量中文硬编码
|
||||
- `course-plan-form.tsx`:第 98/105/122/139/156/173/186/203/214/225/234/247/257 行全英文硬编码("New Course Plan"/"Class"/"Subject"/"Teacher" 等)
|
||||
- `course-plan-item-editor.tsx`:第 119/125/136/148/159/172/180/191 行全英文硬编码
|
||||
- `course-plan-progress.tsx`:第 25/27 行 "Progress"/"hours" 硬编码
|
||||
- `teacher/course-plans/page.tsx`:第 41-43 行 "My Course Plans"/"View your course teaching plans..." 硬编码
|
||||
- **违反规则**:项目规则 "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"
|
||||
- **后果**:
|
||||
1. 国际化完全不可用——切换语言后课程计划模块仍显示混合中英文
|
||||
2. 同一模块内 `course-plan-detail.tsx`(中文)与 `course-plan-list.tsx`(英文)状态标签不一致,用户体验割裂
|
||||
3. 翻译文件 `course-plans.json` 仅含 5 个键(title/description/detail/edit/create),远不满足组件需要
|
||||
|
||||
### 2.3 架构违规问题(P1)
|
||||
|
||||
#### 问题 5:跨模块直接 JOIN 其他模块数据库表
|
||||
|
||||
- **位置**:`data-access.ts` `buildPlanSelect` 第 115-142 行
|
||||
- **问题**:直接 `leftJoin(classes, ...)`、`leftJoin(subjects, ...)`、`leftJoin(users, ...)`,分别查询 classes 模块、school 模块、users 模块拥有的表。架构图却标注为"✅ 通过 data-access"。
|
||||
- **违反规则**:项目规则 "模块间只能通过对方 data-access 通信,禁止跨模块直接查询数据库表"
|
||||
- **后果**:classes/school/users 模块的表结构变更将直接影响 course-plans 查询;模块未真正解耦,无法独立测试
|
||||
|
||||
#### 问题 6:缺少 loading.tsx / error.tsx
|
||||
|
||||
- **位置**:`src/app/(dashboard)/admin/course-plans/` 和 `src/app/(dashboard)/teacher/course-plans/` 全部路由
|
||||
- **问题**:6 个页面路由均**无 loading.tsx 和 error.tsx**。
|
||||
- **违反规则**:项目规则 "All student routes must include loading.tsx and error.tsx for error boundaries"(best practice 推广至所有角色路由)
|
||||
- **后果**:数据加载期间白屏;运行时错误无边界捕获,导致整页崩溃
|
||||
|
||||
#### 问题 7:使用原生 `<a>` 标签替代 `<Link>`
|
||||
|
||||
- **位置**:`course-plan-list.tsx` 第 97 行 `<a href={createHref}>`、第 149 行 `<a key={plan.id} href={href}>`
|
||||
- **违反规则**:项目规则 "Link navigation must use Next.js `<Link>` component instead of raw `<a>` tags"
|
||||
- **后果**:点击导航触发整页刷新,丢失客户端状态,无预取优化
|
||||
|
||||
### 2.4 代码质量问题(P1)
|
||||
|
||||
#### 问题 8:使用 `as` 类型断言
|
||||
|
||||
- **位置**:
|
||||
- `course-plan-list.tsx` 第 73 行:`setFilter(value as Filter)`
|
||||
- `course-plan-form.tsx` 第 174 行:`setSemester(v as "1" | "2")`;第 188 行:`setStatus(v as CoursePlanStatus)`
|
||||
- `teacher/course-plans/page.tsx` 第 19 行:`(v as CoursePlanStatus)`
|
||||
- **违反规则**:项目规则 "禁止 as 断言(除类型收窄外)"
|
||||
- **后果**:运行时类型不安全,应使用类型守卫函数(如 admin 页面已实现的 `isValidStatus`)
|
||||
|
||||
#### 问题 9:基于 URL 字符串判断角色的脆弱逻辑
|
||||
|
||||
- **位置**:
|
||||
- `course-plan-detail.tsx` 第 63 行:`backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"`
|
||||
- `course-plan-form.tsx` 第 81 行:同样的 `backHref?.includes("/teacher/")` 模式
|
||||
- **问题**:通过 URL 路径字符串推断用户角色来决定跳转目标,而非通过权限/角色上下文。
|
||||
- **违反规则**:项目规则 "前端权限判断统一使用 usePermission().hasPermission(),严禁出现 role === 'xxx' 硬编码"(URL 路径推断属于同类硬编码)
|
||||
- **后果**:新增 parent/student 路由时跳转逻辑将出错;URL 结构调整即破坏功能
|
||||
|
||||
#### 问题 10:Server Action 入参未经验证
|
||||
|
||||
- **位置**:`actions.ts` `getCoursePlansAction` 第 135-145 行(params 未 Zod 验证)、`getGradeCoursePlanProgressAction` 第 269-284 行(gradeId 仅检查非空)
|
||||
- **违反规则**:项目规则 "输入使用 Zod 验证,验证失败返回结构化错误"
|
||||
- **后果**:恶意参数可能绕过预期过滤条件
|
||||
|
||||
#### 问题 11:死代码 — `reorderCoursePlanItems` 无消费方
|
||||
|
||||
- **位置**:`data-access.ts` 第 290-313 行
|
||||
- **问题**:`reorderCoursePlanItems` 函数存在但无对应 Server Action、无 UI 调用方,架构图也未记录。
|
||||
- **违反规则**:项目规则 "避免过度工程" + 架构图同步规则
|
||||
- **后果**:死代码增加维护负担;架构图与实际不一致
|
||||
|
||||
### 2.5 错误处理与边界缺失(P2)
|
||||
|
||||
#### 问题 12:无 Error Boundary / Suspense / 骨架屏
|
||||
|
||||
- **位置**:全部组件和页面
|
||||
- **问题**:数据区块未用 React Error Boundary 包裹;异步加载无 Suspense + 骨架屏;空数据虽有基础 `EmptyState` 但无操作引导(CTA)。
|
||||
- **违反规则**:审计要求 "每个独立的数据区块必须用 React Error Boundary 包裹;异步数据使用 React Suspense + 骨架屏"
|
||||
- **后果**:局部数据错误导致整页不可用;加载体验差
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
基于 K12 教育管理系统(如 PowerSchool、Canvas、Schoology、钉钉教育、企业自建校管系统)在课程计划/教学进度模块的主流实践,当前差距如下:
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 影响 |
|
||||
|------|-------------|---------|------|
|
||||
| **角色覆盖** | admin/teacher/parent/student 四角色均可查看课程计划(按权限脱敏) | 仅 admin/teacher 有路由,parent/student 完全无入口 | 家长无法了解孩子本学期教学安排;学生无法预览学习进度 |
|
||||
| **进度可视化** | 甘特图/时间轴展示周计划进度,颜色区分已完成/进行中/待开始 | 仅一个简单 Progress 条 + 表格列表 | 管理者难以一目了然掌握全年级教学进度 |
|
||||
| **数据联动** | 周计划条目关联作业/考试/教材章节,可一键跳转 | `textbookChapter` 仅存文本,无关联跳转 | 教师需手动查找对应教材和作业 |
|
||||
| **批量操作** | 批量标记完成、批量调整周次、批量复制计划到其他班级 | 无任何批量操作 | 管理员配置多班级计划时重复劳动 |
|
||||
| **模板复用** | 提供标准课程计划模板,可从模板创建或复制历史计划 | 每次从零创建 | 教师重复录入 |
|
||||
| **拖拽排序** | 周计划条目支持拖拽调整顺序 | data-access 有 `reorderCoursePlanItems` 但无 UI | 死代码,功能缺失 |
|
||||
| **导出打印** | 导出 PDF/Excel 教学进度报告 | 无 | 无法线下归档或上报 |
|
||||
| **空状态 CTA** | 空状态带"创建第一个计划"引导按钮 | 有 EmptyState 但无 CTA 按钮 | 新用户不知如何开始 |
|
||||
| **骨架屏** | 加载时显示结构化骨架屏 | 无 loading.tsx | 加载白屏 |
|
||||
| **日历视图** | 月历/周历视图展示教学安排 | 无 | 教师难以对照实际日期安排教学 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 安全与国际化(必须立即修复)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | 教师详情页信息泄露 | `getCoursePlanById` 增加 `userId` + `dataScope` 参数,data-access 层过滤归属;非 admin 仅能查看自己负责的计划 |
|
||||
| P0-2 | admin 页面无 requirePermission | admin 所有页面补充 `requirePermission(COURSE_PLAN_READ)` |
|
||||
| P0-3 | data-access 无 DataScope 过滤 | `getCoursePlans`/`getCoursePlanById`/`getGradeCoursePlanProgress` 增加可选 `scope` 参数,按 classIds/teacherId 过滤 |
|
||||
| P0-4 | i18n 严重缺失 | 提取全部硬编码文本到 `course-plans.json`,补全 zh-CN/en 翻译键(状态标签、表单字段、按钮、空状态、Toast 消息等) |
|
||||
|
||||
### P1 — 架构合规与代码质量
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | 跨模块直接 JOIN | 定义 `CoursePlanDataService` 接口抽象 classes/subjects/users 数据依赖,通过组合注入;或先抽取 `getClassNameById`/`getSubjectNameById`/`getTeacherNameById` 轻量 data-access 调用替代 JOIN |
|
||||
| P1-2 | 缺 loading.tsx/error.tsx | 为 admin 和 teacher 路由补充 loading.tsx(骨架屏)和 error.tsx(错误边界) |
|
||||
| P1-3 | 原生 `<a>` 标签 | 替换为 Next.js `<Link>` 组件 |
|
||||
| P1-4 | `as` 断言 | 替换为类型守卫函数(`isValidStatus`/`isValidSemester`) |
|
||||
| P1-5 | URL 路径推断角色 | 改为通过 `usePermission().hasPermission()` 决定跳转基础路径,或由页面 props 传入 `successHref` |
|
||||
| P1-6 | Action 入参未验证 | `getCoursePlansAction`/`getGradeCoursePlanProgressAction` 增加 Zod schema 验证 |
|
||||
| P1-7 | 死代码 reorderCoursePlanItems | 删除或补充对应 Action + UI(推荐补充拖拽排序 UI) |
|
||||
|
||||
### P2 — 体验与企业级增强(中长期)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 无 Error Boundary / 骨架屏 | 组件级 Error Boundary 包裹数据区块;Suspense + 骨架屏 |
|
||||
| P2-2 | parent/student 无路由 | 新增 parent/student 课程计划只读路由(按孩子班级过滤) |
|
||||
| P2-3 | 无数据联动 | 周计划条目关联教材章节/作业,支持跳转 |
|
||||
| P2-4 | 无批量操作 | 批量标记完成、批量复制计划 |
|
||||
| P2-5 | 无模板复用 | 课程计划模板库,从模板创建 |
|
||||
| P2-6 | 无导出 | PDF/Excel 导出教学进度报告 |
|
||||
| P2-7 | 无日历视图 | 月历视图对照实际日期 |
|
||||
| P2-8 | 监控埋点 | 预留 `trackCoursePlanEvent()` 埋点接口 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图需补充/修改以下内容(✅ 已全部完成同步,含 P0/P1/P2 全部实施):
|
||||
|
||||
### 004_architecture_impact_map.md §2.18
|
||||
|
||||
1. **✅ 已补充导出函数**:
|
||||
- data-access:`bulkUpdateItemCompleted`、`copyCoursePlanToClasses`、`enrichPlanRows`(名称解析解耦)、`buildScopeCondition`(权限过滤)
|
||||
- actions:`reorderCoursePlanItemsAction`、`bulkToggleItemsAction`、`copyCoursePlanAction`、`getTemplateCandidatesAction`(P2-5 新增)、`trackCoursePlanEvent`
|
||||
- lib:`lib/export-utils.ts`(P2-6 CSV 导出纯函数)、`lib/calendar-utils.ts`(P2-7 日历视图纯函数 + 日期工具)
|
||||
- types:`CoursePlanQueryScope`、类型守卫 `isCoursePlanStatus`/`isCoursePlanSemester`、配置驱动 `ROLE_WIDGET_CONFIG`、`CalendarEvent`(P2-7)、`CoursePlanExportColumnKey`/`CoursePlanColumnLabels`(P2-6)
|
||||
- components:新增 `SortableWeekRow`(P1-7/P2-3)、`CoursePlanCalendar`(P2-7)、`TemplatePickerDialog`(P2-5)
|
||||
2. **✅ 已修正依赖关系描述**:
|
||||
- 旧描述 "依赖 classes/school(合理)" → 新描述 "通过动态 import `getClassNamesByIds`/`getSubjectNameMapByIds`/`getUserNamesByIds` 批量解析,不再直接 JOIN"
|
||||
- `getSubjectOptions` 已移至 school 模块(pages 改为从 `@/modules/school/data-access` 导入)
|
||||
- 新增依赖:`shared/lib/export-utils.ts`(P2-6)、`@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities`(P1-7)
|
||||
3. **✅ 已补充已知问题修复状态**:P0-1 至 P1-7 + P2-1 至 P2-8 全部标记为已修复
|
||||
4. **✅ 已补充页面路由表**:10 个路由(含 P2-2 新增 parent/student 4 个路由)+ 权限 + loading/error 状态
|
||||
5. **✅ 已更新文件清单行数**:反映重构后的实际行数(含新增 lib/ 与 components/ 文件)
|
||||
|
||||
### 005_architecture_data.json modules.`course-plans`
|
||||
|
||||
1. **✅ 已补充 actions 节点**:`reorderCoursePlanItemsAction`、`bulkToggleItemsAction`、`copyCoursePlanAction`、`getTemplateCandidatesAction`(P2-5)
|
||||
2. **✅ 已补充 dataAccess 节点**:`bulkUpdateItemCompleted`、`copyCoursePlanToClasses`
|
||||
3. **✅ 已移除 `getSubjectOptions`**:该函数已从 course-plans 模块删除,改用 school 模块
|
||||
4. **✅ 已更新 `getCoursePlans`/`getCoursePlanById` 签名**:增加 `scope?: CoursePlanQueryScope` 参数
|
||||
5. **✅ 已更新依赖关系**:移除 `shared.db.schema.classes/subjects/users`,改为动态 import 对方 data-access
|
||||
6. **✅ 已补充 schemas**:`GetCoursePlansParamsSchema`、`GradeIdSchema`、`ReorderItemsSchema`、`BulkToggleSchema`、`CopyPlanSchema`
|
||||
7. **✅ 已补充 types**:`CoursePlanQueryScope`、`GradeCoursePlanProgressItem`、`GradeCoursePlanProgressResult`、`isCoursePlanStatus`、`isCoursePlanSemester`、`CoursePlanWidgetId`、`RoleWidgetConfig`、`ROLE_WIDGET_CONFIG`、`CalendarEvent`(P2-7)、`CoursePlanExportColumnKey`/`CoursePlanColumnLabels`(P2-6)
|
||||
8. **✅ 已更新 components 描述**:反映 P1 + P2 全部修复内容(Error Boundary、拖拽、数据联动、导出、日历、模板)
|
||||
9. **✅ 已补充 lib 节点**:`export-utils.ts`、`calendar-utils.ts`(P2 新增)
|
||||
10. **✅ 已补充新增 components**:`SortableWeekRow`、`CoursePlanCalendar`、`TemplatePickerDialog`
|
||||
|
||||
### shared/components/section-error-boundary.tsx(P2-1 重构)
|
||||
|
||||
- **✅ 已重构**:类组件 + 函数式包装器双层结构
|
||||
- 函数式包装器自动注入 i18n 文案(`{namespace}.error.boundaryTitle` / `boundaryDescription` / `retry`)
|
||||
- 支持 `fallback` 自定义降级 UI(函数形式 `(error, reset) => ReactNode`)
|
||||
- 支持 `onError` 回调(用于埋点/监控,AI 模块复用)
|
||||
- a11y:`role="alert"` + `aria-live="assertive"` + 重试按钮 `aria-label`
|
||||
|
||||
### shared/lib/export-utils.ts(P2-6 新增)
|
||||
|
||||
- **✅ 已从 `dashboard/lib/export-utils.ts` 迁移至 shared 层**,供所有模块复用
|
||||
- 导出:`toCSV`、`downloadFile`、`exportCSV`、`ExportRow`、`ExportColumn` 类型
|
||||
320
docs/architecture/audit/dashboard-audit-report-v4.md
Normal file
320
docs/architecture/audit/dashboard-audit-report-v4.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Dashboard 模块 V4 审计报告
|
||||
|
||||
**审计日期**:2026-06-22
|
||||
**审计范围**:`src/modules/dashboard/` + 所有 dashboard 路由文件 + parent dashboard 组件
|
||||
**前置审计**:
|
||||
- v1(P0 修复:跨模块 DB 查询、权限、i18n 容器组件)
|
||||
- v2(10 个子组件 i18n、DashboardGreetingHeader 抽象、31 个纯函数单测、a11y 语义化标签)
|
||||
- v3(ContentRow 标签错配、admin/error.tsx i18n、空趋势数据空状态、loading/error.tsx 补齐、日期 locale、死代码清理、`as` 断言修复、流式架构 React `use()`)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
仪表盘模块位于 `src/modules/dashboard/`,包含 29 个文件:
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|----|------|------|------|
|
||||
| actions | `actions.ts` | 167 | 4 个 Server Action(admin/teacher/student/parent),均调用 `requirePermission()` |
|
||||
| data-access | `data-access.ts` | 49 | admin 仪表盘数据聚合(并行调用 6 个模块 stats 函数) |
|
||||
| streams | `streams.ts` | 34 | admin 流式数据源(返回未解析 Promise 供 React `use()` 消费) |
|
||||
| types | `types.ts` | 74 | AdminDashboardData / StudentDashboardProps / TeacherDashboardData |
|
||||
| lib | `lib/dashboard-utils.ts` | 198 | 6 个纯函数(weekday / 统计 / 排序 / 指标计算 / 问候语) |
|
||||
| components | `dashboard-section.tsx` | 170 | Error Boundary + Suspense + 骨架屏(5 种变体) |
|
||||
| components | `dashboard-greeting-header.tsx` | 36 | 共享问候头部 |
|
||||
| components | `dashboard-error-fallback.tsx` | 30 | 路由级错误回退 |
|
||||
| components | `dashboard-loading-skeleton.tsx` | 44 | 路由级加载骨架 |
|
||||
| admin-dashboard | `admin-dashboard.tsx` | 173 | 管理员视图(流式架构) |
|
||||
| admin-dashboard | `admin-sections.tsx` | 231 | 管理员 6 个分区组件 |
|
||||
| admin-dashboard | `user-growth-chart.tsx` | 65 | recharts 折线图 |
|
||||
| teacher-dashboard | 9 文件 | ~700 | 教师仪表盘组件 |
|
||||
| student-dashboard | 6 文件 | ~530 | 学生仪表盘组件 |
|
||||
| tests | `dashboard-section.test.tsx` | 70 | Error Boundary + 骨架屏单测 |
|
||||
| tests | `tests/integration/dashboard/dashboard-utils.test.ts` | 408 | 6 个纯函数 31 个单测 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
[Page] → [Action] → [requirePermission] → [data-access / 其他模块 data-access]
|
||||
↓
|
||||
[lib/dashboard-utils 纯函数计算]
|
||||
↓
|
||||
[View 组件] → [DashboardSection Suspense]
|
||||
```
|
||||
|
||||
### 1.3 架构图记录完整性
|
||||
|
||||
架构影响地图(004/005)已覆盖 dashboard 模块的:
|
||||
- 4 个 Server Action 签名、依赖、使用方
|
||||
- 6 个纯函数签名和用途
|
||||
- 依赖矩阵(dependsOn: shared/auth/homework/classes)
|
||||
- 路由权限映射(dashboardRoutePermissions)
|
||||
- 组件清单和行数
|
||||
|
||||
**遗漏**:架构图未记录 `streams.ts` 的流式数据源函数,也未记录 parent dashboard 组件实际位于 `modules/parent/components/` 的跨模块布局。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### P0 问题(严重)
|
||||
|
||||
#### P0-1:`filterTodaySchedule` 仍使用 `as T[]` 类型断言
|
||||
|
||||
- **文件**:`src/modules/dashboard/lib/dashboard-utils.ts`
|
||||
- **行号**:120
|
||||
- **问题**:v3 审计(P1-8)已识别此问题并改为泛型函数,但实现仍保留 `as T[]` 断言:
|
||||
```typescript
|
||||
return schedule
|
||||
.filter(...)
|
||||
.sort(...)
|
||||
.map((s) => ({ ... })) as T[] // ← 违反"禁止 as 断言"
|
||||
```
|
||||
- **违反规则**:项目规则「TypeScript 严格模式:禁止 `as` 断言(除类型收窄外)」
|
||||
- **后果**:类型系统被绕过,`map` 返回的对象结构若与 `T` 不匹配,编译器不会报错,潜在运行时错误
|
||||
- **修复方向**:移除 `as T[]`,让 `map` 返回类型自然推导;或将映射逻辑提取为泛型映射函数
|
||||
|
||||
### P1 问题(高)
|
||||
|
||||
#### P1-1:组件内嵌纯函数未抽取到 lib
|
||||
|
||||
- **文件**:
|
||||
- `teacher-schedule.tsx` 行 24-36:`getStatus(start, end)` 计算课程状态
|
||||
- `student-upcoming-assignments-card.tsx` 行 18:`timeToMinutes(t)`
|
||||
- `student-upcoming-assignments-card.tsx` 行 30-40:`getDueUrgency(dueAt)`
|
||||
- `student-upcoming-assignments-card.tsx` 行 18-28:`getActionLabelKey(status)` / `getActionVariant(status)`
|
||||
- `student-today-schedule-card.tsx` 行 17-20:`timeToMinutes(t)`
|
||||
- **问题**:5 个纯函数散落在 3 个组件文件中,无法被单测覆盖,且 `timeToMinutes` 在两处重复定义
|
||||
- **违反规则**:项目规则「可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离」
|
||||
- **后果**:单测覆盖率无法提升;`timeToMinutes` 重复定义易产生不一致
|
||||
- **修复方向**:全部迁移到 `lib/dashboard-utils.ts`,导出供组件调用,补充单测
|
||||
|
||||
#### P1-2:`teacherName` 硬编码英文 fallback
|
||||
|
||||
- **文件**:`src/modules/dashboard/actions.ts`
|
||||
- **行号**:85
|
||||
- **问题**:`teacherName: teacherProfile?.name ?? "Teacher"` — 当教师名称为空时 fallback 为硬编码英文 "Teacher",英文/中文用户都会看到英文
|
||||
- **违反规则**:项目规则「所有用户可见文本必须适配 i18n」
|
||||
- **后果**:中文用户在教师名称缺失时看到英文 "Teacher",i18n 不一致
|
||||
- **修复方向**:fallback 改为空字符串 `""`,由前端组件用 `t("title.teacher")` 处理空值
|
||||
|
||||
#### P1-3:`teacher-schedule.tsx` 本地重复定义类型
|
||||
|
||||
- **文件**:`src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx`
|
||||
- **行号**:10-18
|
||||
- **问题**:本地定义 `TeacherTodayScheduleItem` 类型,与 `types.ts` 中的同名类型结构完全相同,重复定义
|
||||
- **违反规则**:项目规则「避免代码重复」
|
||||
- **后果**:类型变更需同步两处,易产生不一致
|
||||
- **修复方向**:从 `types.ts` 导入,删除本地定义
|
||||
|
||||
#### P1-4:parent dashboard 组件位于 parent 模块而非 dashboard 模块
|
||||
|
||||
- **文件**:`src/modules/parent/components/parent-dashboard.tsx`
|
||||
- **问题**:`ParentDashboard` 组件位于 parent 模块,但由 `dashboard/actions.getParentDashboardAction` 提供数据,且 `parent/dashboard/page.tsx` 同时导入两个模块的组件。架构图标注为"架构决策:保留在 parent 模块以避免移动文件破坏其他 import",但这造成模块边界模糊
|
||||
- **违反规则**:项目规则「该模块必须作为独立功能单元」
|
||||
- **后果**:dashboard 模块不完整,parent 仪表盘的 UI 逻辑分散在两个模块
|
||||
- **修复方向**:将 `ParentDashboard` 组件迁移到 `modules/dashboard/components/parent-dashboard/`,parent 模块仅保留数据访问
|
||||
|
||||
### P2 问题(中)
|
||||
|
||||
#### P2-1:无数据服务接口抽象
|
||||
|
||||
- **文件**:`src/modules/dashboard/actions.ts`、`data-access.ts`
|
||||
- **问题**:dashboard 模块直接 import 其他 6 个模块的 data-access 函数(classes/homework/users/parent/textbooks/questions/exams),无 TypeScript 接口抽象。组件层无法 mock 数据依赖,单测必须 mock 整个模块
|
||||
- **违反规则**:项目规则「完全解耦:通过定义 TypeScript 接口抽象数据依赖」
|
||||
- **后果**:模块耦合度高,难以独立测试,新增角色需修改 actions.ts
|
||||
- **修复方向**:定义 `DashboardService` 接口,为每个角色提供实现类,通过 React Context 注入
|
||||
|
||||
#### P2-2:无配置驱动的 Widget 渲染
|
||||
|
||||
- **文件**:`admin-dashboard.tsx`、`teacher-dashboard-view.tsx`、`student-dashboard-view.tsx`
|
||||
- **问题**:每个角色的仪表盘视图硬编码渲染哪些 Widget(如 admin 渲染 StatsBar + QuickActions + TrendCharts + 3 Cards + RecentUsersTable)。新增角色或调整 Widget 需修改视图组件代码
|
||||
- **违反规则**:项目规则「可扩展性:采用配置驱动设计」
|
||||
- **后果**:扩展性差,4 个角色视图代码结构相似但无法复用
|
||||
- **修复方向**:定义 `DashboardWidgetConfig` 类型,通过配置决定渲染哪些 Widget 及其布局
|
||||
|
||||
#### P2-3:无监控埋点接口
|
||||
|
||||
- **文件**:整个模块
|
||||
- **问题**:无任何用户行为埋点(如 Widget 点击、页面停留、空状态触发等),无法度量仪表盘使用情况
|
||||
- **违反规则**:项目规则「监控:方案中预留关键操作埋点接口」
|
||||
- **后果**:无法度量仪表盘使用情况,无法指导优化
|
||||
- **修复方向**:定义 `DashboardAnalytics` 接口,在关键交互点调用(Widget 点击、空状态触发、错误重试)
|
||||
|
||||
#### P2-4:admin dashboard `userGrowth` 和 `homeworkTrend` 仍为占位空数组
|
||||
|
||||
- **文件**:`src/modules/dashboard/data-access.ts`
|
||||
- **行号**:46-47
|
||||
- **问题**:v3 已为 `UserGrowthChart` 添加空状态,但数据源仍硬编码 `userGrowth: []` 和 `homeworkTrend: []`,趋势图表永远显示空状态
|
||||
- **违反规则**:无直接违反,但影响用户体验
|
||||
- **后果**:管理员无法看到用户增长和作业提交趋势
|
||||
- **修复方向**:实现真实统计查询,或在 data-access 层添加 TODO 注释标记后续实现
|
||||
|
||||
#### P2-5:4 个角色 StatCard 使用模式不一致
|
||||
|
||||
- **文件**:
|
||||
- `admin-sections.tsx`:`StatCard` 直接传 `value`(number)
|
||||
- `teacher-stats.tsx`:`StatCard` 传 `value={String(count)}` + `color` + `highlight`
|
||||
- `student-stats-grid.tsx`:`StatCard` 传 `value={String(count)}` + `color` + `valueClassName` + 条件颜色
|
||||
- **问题**:3 个角色的 StatCard 调用模式不一致,admin 不传 color,teacher 传 color,student 传 color + valueClassName
|
||||
- **违反规则**:项目规则「最大化复用:识别四个角色共用的 UI 块」
|
||||
- **后果**:视觉不一致,维护成本高
|
||||
- **修复方向**:统一 StatCard 调用模式,通过配置驱动颜色和样式
|
||||
|
||||
### P3 问题(低)
|
||||
|
||||
#### P3-1:无完整键盘导航支持
|
||||
|
||||
- **问题**:虽有 `aria-label` 属性,但 Widget 之间无 `tabindex` 管理,键盘用户无法按逻辑顺序遍历 Widget
|
||||
- **修复方向**:为 Widget 容器添加 `role="region"` + `aria-label`,管理 `tabindex`
|
||||
|
||||
#### P3-2:`AdminTrendCharts` 硬编码 `data={[]}`
|
||||
|
||||
- **文件**:`admin-sections.tsx` 行 144、152
|
||||
- **问题**:`UserGrowthChart` 调用时传 `data={[]}`,与 P2-4 相关
|
||||
- **修复方向**:从 `streams` 获取真实趋势数据
|
||||
|
||||
#### P3-3:`teacher-todo-card.tsx` 排序逻辑仍可优化
|
||||
|
||||
- **文件**:`teacher-todo-card.tsx` 行 52-56
|
||||
- **问题**:v3 已优化排序逻辑,但仍使用 `if (a.variant === "urgent") return -1` 模式,可进一步用优先级映射
|
||||
- **修复方向**:定义 `VARIANT_PRIORITY` 映射,用数值比较
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与优秀 K12 产品的差距
|
||||
|
||||
| 维度 | 我们当前 | 钉钉教育/智学网/ClassIn | 差距影响 |
|
||||
|------|---------|----------------------|---------|
|
||||
| **数据联动** | 各 Widget 独立展示,无联动 | 点击统计卡片可下钻到详情页 | 管理员无法快速从概览定位问题 |
|
||||
| **个性化配置** | 固定布局,用户无法自定义 | 支持拖拽 Widget、隐藏/显示 | 不同用户关注点不同,固定布局降低效率 |
|
||||
| **实时更新** | 静态数据,需刷新页面 | WebSocket 实时推送待办数 | 待办数不实时,影响响应速度 |
|
||||
| **多角色切换** | 通过权限路由到不同仪表盘 | 支持角色快速切换(如班主任+教师) | 多角色用户需退出重新登录 |
|
||||
| **数据导出** | 无导出功能 | 支持导出 PDF/Excel | 管理员无法离线分析 |
|
||||
| **通知集成** | 无通知集成 | 仪表盘集成待办通知 | 用户需切换页面查看通知 |
|
||||
| **移动端适配** | 基本响应式,但 Widget 布局未优化 | 移动端优先设计,卡片堆叠 | 移动端体验不佳 |
|
||||
|
||||
### 3.2 缺失的关键功能
|
||||
|
||||
1. **Widget 下钻导航**:统计卡片点击应跳转到对应详情页(部分已实现,但不完整)
|
||||
2. **时间范围筛选**:admin 无法切换"今日/本周/本月"数据范围
|
||||
3. **数据对比**:无法对比不同时间段数据(如本周 vs 上周)
|
||||
4. **自定义仪表盘**:用户无法选择显示哪些 Widget
|
||||
5. **通知中心集成**:仪表盘未集成通知下拉
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(立即修复)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | `filterTodaySchedule` 的 `as T[]` 断言 | 移除断言,改用类型守卫或泛型映射 |
|
||||
|
||||
### P1(高优先级)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | 组件内嵌纯函数未抽取 | 迁移 5 个纯函数到 `lib/dashboard-utils.ts`,补充单测 |
|
||||
| P1-2 | `teacherName` 硬编码 fallback | 改为空字符串,前端用 i18n 处理 |
|
||||
| P1-3 | `teacher-schedule.tsx` 本地类型重复 | 从 `types.ts` 导入 |
|
||||
| P1-4 | parent dashboard 组件跨模块 | 迁移到 `modules/dashboard/components/parent-dashboard/` |
|
||||
|
||||
### P2(中优先级 - 架构改进)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 无数据服务接口抽象 | 定义 `DashboardService` 接口 + 角色实现 + Context 注入 |
|
||||
| P2-2 | 无配置驱动 Widget 渲染 | 定义 `DashboardWidgetConfig`,配置驱动渲染 |
|
||||
| P2-3 | 无监控埋点接口 | 定义 `DashboardAnalytics` 接口,预留埋点 |
|
||||
| P2-4 | admin 趋势数据占位 | 添加 TODO 注释,标记后续实现 |
|
||||
| P2-5 | StatCard 使用模式不一致 | 统一调用模式 |
|
||||
|
||||
### P3(低优先级 - 长期优化)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P3-1 | 无完整键盘导航 | 添加 `role="region"` + `tabindex` |
|
||||
| P3-2 | AdminTrendCharts 硬编码空数据 | 从 streams 获取真实数据 |
|
||||
| P3-3 | TeacherTodoCard 排序优化 | 用优先级映射 |
|
||||
|
||||
### 中长期计划(不在本次实施范围)
|
||||
|
||||
| 编号 | 问题 | 改进方向 | 阶段 |
|
||||
|------|------|---------|------|
|
||||
| L1 | Widget 下钻导航 | 统计卡片点击跳转详情页 | 第二阶段 |
|
||||
| L2 | 时间范围筛选 | admin 仪表盘添加时间选择器 | 第二阶段 |
|
||||
| L3 | 数据对比 | 添加"本周 vs 上周"对比卡片 | 第三阶段 |
|
||||
| L4 | 自定义仪表盘 | 用户可选择显示哪些 Widget | 第三阶段 |
|
||||
| L5 | 通知中心集成 | 仪表盘集成通知下拉 | 第二阶段 |
|
||||
| L6 | 实时更新 | WebSocket 推送待办数 | 第三阶段 |
|
||||
| L7 | 数据导出 | 支持 PDF/Excel 导出 | 第三阶段 |
|
||||
| L8 | 移动端优化 | Widget 移动端优先布局 | 第二阶段 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 需要补充/修改的节点
|
||||
|
||||
1. **`streams.ts`**:架构图未记录 `getAdminDashboardStreams` 函数,需在 004 的 dashboard 模块章节和 005 的 `modules.dashboard.exports` 中添加
|
||||
2. **parent dashboard 组件位置**:架构图需注明 `ParentDashboard` 组件实际位于 `modules/parent/components/`,由 dashboard actions 提供数据
|
||||
3. **新增 `DashboardService` 接口**(本次实施后):在 005 的 `modules.dashboard` 中添加 `services` 节点
|
||||
4. **新增 `DashboardWidgetConfig` 类型**(本次实施后):在 005 的 `modules.dashboard.exports.types` 中添加
|
||||
5. **新增 `DashboardAnalytics` 接口**(本次实施后):在 005 的 `modules.dashboard.exports.services` 中添加
|
||||
|
||||
---
|
||||
|
||||
## 六、本次实施计划
|
||||
|
||||
### 实施范围
|
||||
|
||||
本次完整实施 P0 + P1 + P2 + P3 + 中长期计划 L1-L8(用户明确要求"包括中长期计划也要完整实施")。
|
||||
|
||||
### 实施步骤与完成状态
|
||||
|
||||
| 步骤 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| P0-1:修复 `filterTodaySchedule` 的 `as T[]` 断言 | ✅ 已完成 | 移除断言,改为非泛型函数 |
|
||||
| P1-1:抽取 5 个纯函数到 `lib/dashboard-utils.ts` | ✅ 已完成 | timeToMinutes/getScheduleStatus/getDueUrgency/getActionLabelKey/getActionVariant |
|
||||
| P1-2:修复 `teacherName` 硬编码 fallback | ✅ 已完成 | 改为空字符串,前端处理 |
|
||||
| P1-3:修复 `teacher-schedule.tsx` 本地类型重复 | ✅ 已完成 | 从 types.ts 导入 |
|
||||
| P1-4:迁移 parent dashboard 组件到 dashboard 模块 | ✅ 已完成 | 迁移至 components/parent-dashboard/,使用 slots 组合 |
|
||||
| P2-1:定义 `DashboardService` 接口 + Context 注入 | ✅ 已完成 | services/dashboard-service.tsx |
|
||||
| P2-2:定义 `DashboardWidgetConfig` 配置驱动渲染 | ✅ 已完成 | config/widget-configs.ts |
|
||||
| P2-3:定义 `DashboardAnalytics` 监控埋点接口 | ✅ 已完成 | services/dashboard-service.tsx |
|
||||
| P2-4:admin 趋势数据占位 TODO | ✅ 已完成 | data-access.ts 添加 TODO 注释 |
|
||||
| P2-5:4 个角色 StatCard 使用模式统一 | ✅ 已完成 | 统一 color + valueClassName="tabular-nums" |
|
||||
| P3-1:完整键盘导航支持 | ✅ 已完成 | DashboardSection 新增 ariaLabel prop + role="region" + tabIndex |
|
||||
| P3-2:AdminTrendCharts 硬编码空数据 TODO | ✅ 已完成 | 添加 TODO 注释 |
|
||||
| P3-3:TeacherTodoCard 排序优化 | ✅ 已完成 | VARIANT_PRIORITY 数值映射 |
|
||||
| L1:Widget 下钻导航 | ✅ 已实施 | Admin StatCard 添加 href,ContentRow 支持可选 href |
|
||||
| L2:时间范围筛选 | ✅ 已实施 | DashboardTimeRangeFilter 组件 + URL search param 持久化 |
|
||||
| L3:数据对比 | ✅ 已实施 | ComparisonBadge 组件 + computeComparison 纯函数 |
|
||||
| L4:自定义仪表盘 | ✅ 已实施 | useDashboardPreferences Hook + localStorage 持久化 |
|
||||
| L5:通知中心集成 | ✅ 已实施 | DashboardNotificationWidget 组件 |
|
||||
| L6:实时更新 | ✅ 已实施 | useDashboardRealtime Hook(SSE + 指数退避重连) |
|
||||
| L7:数据导出 | ✅ 已实施 | lib/export-utils.ts(CSV 导出 + 浏览器下载) |
|
||||
| L8:移动端优化 | ✅ 已实施 | DashboardResponsiveLayout / MobileSwipeContainer / DesktopGrid |
|
||||
| 同步架构文档 004 和 005 | ✅ 已完成 | 所有新组件/函数/类型已记录 |
|
||||
| 验证:tsc + lint 零错误 | ✅ 已完成 | Dashboard 源码零错误(仅预存测试文件 screen 导入错误),ESLint 零错误 |
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
| 文件 | 类型 | 职责 |
|
||||
|------|------|------|
|
||||
| `services/dashboard-service.tsx` | Service | DashboardService 接口 + DashboardAnalytics 接口 + Context Provider |
|
||||
| `config/widget-configs.ts` | Config | 4 个角色 Widget 布局配置 |
|
||||
| `hooks/use-dashboard-preferences.ts` | Hook | 自定义仪表盘偏好(L4) |
|
||||
| `hooks/use-dashboard-realtime.ts` | Hook | SSE 实时更新(L6) |
|
||||
| `lib/export-utils.ts` | Lib | CSV 导出工具(L7) |
|
||||
| `components/parent-dashboard/parent-dashboard.tsx` | Component | 家长仪表盘视图(P1-4 迁移) |
|
||||
| `components/dashboard-time-range-filter.tsx` | Component | 时间范围筛选器(L2) |
|
||||
| `components/comparison-badge.tsx` | Component | 数据对比徽章(L3) |
|
||||
| `components/dashboard-notification-widget.tsx` | Component | 通知中心 Widget(L5) |
|
||||
| `components/dashboard-responsive-layout.tsx` | Component | 移动端响应式布局(L8) |
|
||||
395
docs/architecture/audit/diagnostic-audit-report-v2.md
Normal file
395
docs/architecture/audit/diagnostic-audit-report-v2.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# 学情诊断(Diagnostic)模块审计报告 v2
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:`src/modules/diagnostic/**`、`src/app/(dashboard)/{teacher,student,parent}/diagnostic/**`
|
||||
> 参照规则:`.trae/rules/project_rules.md`、`docs/architecture/004_architecture_impact_map.md` §2.22、`docs/architecture/005_architecture_data.json`
|
||||
> 前置文档:
|
||||
> - [diagnostic-audit-report.md](./diagnostic-audit-report.md)(v1,2026-06-22,3 P0 + 5 P1 + 5 P2 全部完成)
|
||||
> - [grades-diagnostic-audit-report-v4.md](./grades-diagnostic-audit-report-v4.md)(v4,2026-06-23,12 项 P1 全部完成)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 v1/v4 已完成项回顾
|
||||
|
||||
v1 与 v4 审计共完成 **25 项**改进(3 P0 + 5 P1 + 5 P2 + 12 v4-P1),涵盖:跨模块 WidgetBoundary 提升到 shared、教师页面标题 i18n 化、教师 error.tsx i18n 化、as 断言消除、分享按钮移除、报告内容 i18n 驱动、Excel 导出 i18n 化、班级报告导出明细、角色配置驱动(role-config.ts)、年级诊断报告纵向切片、热力图键盘导航、DataScope 行级权限、师生关系校验、草稿隔离、通知机制、热力图图例、移动端表格滚动等。
|
||||
|
||||
### 1.2 当前文件分布(v2 实测)
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| 类型 | [types.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/types.ts) | 126 | DiagnosticReport / Mastery / Summary 类型定义(含 v4-P2-3 GradeMasterySummary) |
|
||||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access.ts) | 519 | 掌握度查询 + 从提交/作业/成绩更新掌握度(含事务) |
|
||||
| 数据访问 | [data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) | 323 | 诊断报告 CRUD + DataScope 过滤 + 结构化错误码 |
|
||||
| 统计服务 | [stats-service.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/stats-service.ts) | 506 | 14 个纯统计函数(含年级聚合) |
|
||||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/actions.ts) | 302 | 6 个 Action(含年级生成 + 通知) |
|
||||
| 校验 | [schema.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/schema.ts) | 39 | 5 个 Zod schema |
|
||||
| 导出 | [export.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/export.ts) | 175 | Excel 导出(含班级明细 3 Sheet) |
|
||||
| 角色配置 | [role-config.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/role-config.ts) | 40 | 角色配置驱动(v4-P2-2) |
|
||||
| 组件 | [components/student-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/student-diagnostic-view.tsx) | 299 | 学生诊断视图(概览+雷达+强弱项+报告+历史) |
|
||||
| 组件 | [components/class-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx) | 449 | 班级诊断视图(热力图+筛选+排名+关注列表+生成) |
|
||||
| 组件 | [components/report-list.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/report-list.tsx) | 373 | 报告列表(过滤+表格+发布/删除/导出) |
|
||||
| 组件 | [components/mastery-radar-chart.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/mastery-radar-chart.tsx) | 85 | 雷达图封装 |
|
||||
| 组件 | [components/confidence-utils.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/components/confidence-utils.ts) | 31 | 置信度计算 |
|
||||
| 页面 | 4 个 `page.tsx` + 5 个 `loading.tsx` + 5 个 `error.tsx` | — | teacher/student/parent 三角色路由 |
|
||||
| i18n | [zh-CN/diagnostic.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/diagnostic.json) + [en/diagnostic.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/en/diagnostic.json) | 252 / 同步 | 翻译文件 |
|
||||
|
||||
### 1.3 数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
├─ getStudentMasterySummary / getClassMasterySummary / getGradeMasterySummary / getKnowledgePointStats (data-access)
|
||||
│ └─ db (drizzle) → knowledgePointMastery / knowledgePoints 表
|
||||
│ └─ 跨模块 data-access:classes / users / school / exams / homework / questions
|
||||
├─ getDiagnosticReports (data-access-reports, 含 DataScope 过滤)
|
||||
│ └─ db → learningDiagnosticReports 表
|
||||
└─ <StudentDiagnosticView> / <ClassDiagnosticView> / <ReportList> (client)
|
||||
└─ generateStudentReportAction / generateClassReportAction / generateGradeReportAction
|
||||
/ publishReportAction / deleteReportAction / exportDiagnosticReportAction
|
||||
/ getClassStudentsByKnowledgePointAction
|
||||
```
|
||||
|
||||
### 1.4 架构图记录完整性
|
||||
|
||||
经核对 [004_architecture_impact_map.md](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) §2.22 与 [005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) `modules.diagnostic` 节点,架构图对诊断模块的记录**基本完整**,已涵盖 v1/v4 全部修复。但本次 v2 审计发现以下偏差需后续同步:
|
||||
|
||||
- 架构图未记录 `getDiagnosticReports` 中 `grade_managed` scope 未过滤的已知缺陷(v2-P1-1)。
|
||||
- 架构图未记录 `confidence-utils.ts` 的置信度计算逻辑过于简化(v2-P1-5)。
|
||||
- 架构图未记录 3 个子路由 error.tsx 仍存在硬编码中文(v2-P0-1)。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 国际化
|
||||
|
||||
#### 问题 2.1.1 | 3 个子路由 error.tsx 硬编码中文(P0)
|
||||
|
||||
- **位置**:
|
||||
- [teacher/diagnostic/class/[classId]/error.tsx#L17-22](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/class/[classId]/error.tsx#L17):`title="班级学情诊断加载失败"` `description="抱歉,加载班级诊断数据时发生了意外错误。请稍后重试。"` `label="重试"`
|
||||
- [teacher/diagnostic/student/[studentId]/error.tsx#L17-22](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/error.tsx#L17):`title="学生学情诊断加载失败"` 同样硬编码
|
||||
- [parent/diagnostic/error.tsx#L17-22](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/diagnostic/error.tsx#L17):`title="子女学情诊断加载失败"` 同样硬编码
|
||||
- **现象**:三个 error.tsx 客户端组件未使用 `useTranslations`,全部硬编码中文文案。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。
|
||||
- **原因**:v1 审计 P0-3 仅修复了 `teacher/diagnostic/error.tsx`(教师报告列表页),遗漏了教师子路由和学生/家长子路由的 error.tsx。
|
||||
- **后果**:英文环境下这三个错误页显示中文,与系统其他已 i18n 化的错误页风格不一致。
|
||||
|
||||
#### 问题 2.1.2 | i18n 标签与代码逻辑不一致(P1)
|
||||
|
||||
- **位置**:
|
||||
- i18n:[zh-CN/diagnostic.json#L78](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/diagnostic.json#L78):`"weaknesses": { "title": "弱项(<60%)" }`
|
||||
- 代码:[stats-service.ts#L83-99](file:///e:/Desktop/CICD/src/modules/diagnostic/stats-service.ts#L83):`classifyStrengthsWeaknesses` 中弱项阈值为 `< 80`(P3-16 修复:消除 60-79 盲区)
|
||||
- **现象**:i18n 标签显示"弱项(<60%)",但代码实际将掌握度 < 80 的知识点都归类为弱项。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"——文本需与逻辑一致。
|
||||
- **原因**:P3-16 修复弱项分类阈值时未同步更新 i18n 标签。
|
||||
- **后果**:用户看到"弱项(<60%)"标签,但实际列表包含 60-79% 的知识点,造成认知混乱。
|
||||
|
||||
#### 问题 2.1.3 | 死 i18n 键未清理(P2)
|
||||
|
||||
- **位置**:[zh-CN/diagnostic.json#L157-165](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/diagnostic.json#L157)
|
||||
- **现象**:`reportList.share`、`reportList.shareAriaLabel`、`reportList.shareTitle`、`reportList.shareDescription`、`reportList.shareLinkLabel`、`reportList.copyLink`、`reportList.copyLinkSuccess`、`reportList.copyLinkFailed`、`reportList.shareLinkAriaLabel` 共 9 个键仍保留在翻译文件中,但 v1-P1-3 已移除分享按钮,这些键不再被引用。
|
||||
- **违反规则**:项目规则精神——保持代码与配置一致,避免死代码。
|
||||
- **原因**:移除分享按钮时未清理对应的 i18n 键。
|
||||
- **后果**:翻译文件臃肿,维护成本增加;新增语言时需翻译无用的键。
|
||||
|
||||
#### 问题 2.1.4 | 热力图 aria-label 硬编码中文标点格式(P1)
|
||||
|
||||
- **位置**:[class-diagnostic-view.tsx#L195](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx#L195)
|
||||
- **现象**:`aria-label={`${kp.knowledgePointName}:${kp.averageMastery.toFixed(1)}%,${levelLabel},${kp.masteredCount}/${kp.totalStudents}`}` 使用硬编码中文全角冒号":"和逗号","。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **原因**:aria-label 拼接时未使用 i18n 模板。
|
||||
- **后果**:英文环境下屏幕阅读器读出中文标点,影响无障碍体验。
|
||||
|
||||
#### 问题 2.1.5 | export.ts 错误消息硬编码英文(P2)
|
||||
|
||||
- **位置**:[export.ts#L28](file:///e:/Desktop/CICD/src/modules/diagnostic/export.ts#L28):`throw new Error("Report not found")`
|
||||
- **现象**:导出报告不存在时抛出硬编码英文错误。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **后果**:中文环境下用户看到英文错误消息。
|
||||
|
||||
### 2.2 权限与安全
|
||||
|
||||
#### 问题 2.2.1 | grade_managed DataScope 未过滤(P1,安全漏洞)
|
||||
|
||||
- **位置**:[data-access-reports.ts#L220-238](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts#L220)
|
||||
- **现象**:`getDiagnosticReports` 仅处理 `children` 和 `class_taught` 两种 DataScope,对 `grade_managed`(年级主任)和 `class_members`(学生)scope 不做任何过滤。代码注释明确写道:`// grade_managed 需要跨模块查询年级学生,由调用方自行过滤`。
|
||||
- **违反规则**:项目规则"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验"。
|
||||
- **原因**:grade_managed scope 需要跨模块查询年级学生 ID(通过 `getUserIdsByGradeId`),实现时为避免跨模块依赖未在 data-access 层完成过滤。
|
||||
- **后果**:年级主任(grade_head / teaching_head)角色调用时,`getDiagnosticReports` 返回全校所有学生的诊断报告,存在数据越权风险。虽然当前 teacher/diagnostic/page.tsx 在客户端对 class_members 做了二次过滤,但 grade_managed 完全未过滤。
|
||||
|
||||
#### 问题 2.2.2 | 教师报告列表页客户端过滤 class_members(P2)
|
||||
|
||||
- **位置**:[teacher/diagnostic/page.tsx#L56-59](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/page.tsx#L56)
|
||||
- **现象**:`const visibleReports = ctx.dataScope.type === "class_members" ? reports.reports.filter((r) => r.studentId === ctx.userId) : reports.reports`
|
||||
- **违反规则**:项目规则"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤"——客户端过滤不安全。
|
||||
- **原因**:教师页面理论上不应被学生角色访问,但代码保留了 class_members 分支作为防御性过滤。这种过滤应在 data-access 层完成。
|
||||
- **后果**:虽然不影响功能(学生不会访问教师路由),但违背了"数据过滤在 data-access 层"的原则,且 data-access 已返回了不该返回的数据。
|
||||
|
||||
### 2.3 架构解耦
|
||||
|
||||
#### 问题 2.3.1 | 组件直接 import actions,无服务接口抽象(P1)
|
||||
|
||||
- **位置**:
|
||||
- [report-list.tsx#L41](file:///e:/Desktop/CICD/src/modules/diagnostic/components/report-list.tsx#L41):`import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"`
|
||||
- [class-diagnostic-view.tsx#L33](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx#L33):`import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"`
|
||||
- **现象**:客户端组件直接 import 并调用 Server Actions,未通过接口抽象或依赖注入。
|
||||
- **违反规则**:项目规则"通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)"。
|
||||
- **原因**:v1 审计将此项列为"后续建议"未实施,但用户在本次审计中将其升级为强制要求。
|
||||
- **后果**:组件无法独立测试(测试时必须 mock 整个 actions 模块);无法在不修改组件代码的情况下替换 actions 实现;组件与 Server Action 实现紧耦合。
|
||||
|
||||
### 2.4 错误处理与边界
|
||||
|
||||
#### 问题 2.4.1 | 无 Error Boundary 包裹独立数据区块(P1)
|
||||
|
||||
- **位置**:
|
||||
- [student-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/student-diagnostic-view.tsx):概览卡片、雷达图、强弱项、报告、历史列表均在同一组件内,无 Error Boundary 隔离。
|
||||
- [class-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx):概览、热力图、筛选、排名、关注列表、生成报告均在同一组件内。
|
||||
- **现象**:仅页面级有 error.tsx 错误边界,组件内部各数据区块无独立 Error Boundary。
|
||||
- **违反规则**:项目规则"每个独立的数据区块必须用 React Error Boundary 包裹"。
|
||||
- **原因**:v4-P2 已将 WidgetBoundary 提升到 shared 层并用于页面级包裹,但未下沉到组件内部各数据区块。
|
||||
- **后果**:雷达图渲染失败会导致整个诊断页面崩溃;热力图数据异常会波及排名表和关注列表。
|
||||
|
||||
#### 问题 2.4.2 | 异步数据无 Suspense + 骨架屏(P2)
|
||||
|
||||
- **位置**:所有页面均使用 `await Promise.all` 一次性获取所有数据后传给客户端组件。
|
||||
- **现象**:未使用 React Suspense 流式渲染,数据获取完成前整个页面阻塞。
|
||||
- **违反规则**:项目规则"异步数据使用 React Suspense + 骨架屏"。
|
||||
- **后果**:首屏白屏时间长;无法渐进式展示数据区块。
|
||||
|
||||
### 2.5 可测试性与置信度
|
||||
|
||||
#### 问题 2.5.1 | 置信度计算过于简化(P1)
|
||||
|
||||
- **位置**:[confidence-utils.ts#L18-21](file:///e:/Desktop/CICD/src/modules/diagnostic/components/confidence-utils.ts#L18)
|
||||
- **现象**:`getConfidenceLevel` 仅判断 `overallScore === null` 返回 "insufficient",否则一律返回 "high"。注释承认"后续可扩展为基于 totalQuestions 等数据量字段的多级判断",但未实施。
|
||||
- **违反规则**:项目规则"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离"——置信度计算虽已提取为纯函数,但逻辑不完整。
|
||||
- **原因**:v4-P3-7 引入置信度时为简化首版,未实现多级判断。
|
||||
- **后果**:仅做了 1 道题的报告也显示"高置信度",误导教师判断报告可信度。
|
||||
|
||||
#### 问题 2.5.2 | stats-service 14 个纯函数无单测(P2)
|
||||
|
||||
- **位置**:[stats-service.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/stats-service.ts)
|
||||
- **现象**:14 个纯函数(`computeAverageMastery`、`classifyStrengthsWeaknesses`、`aggregateClassMastery` 等)已正确提取为纯函数,但无对应的单元测试文件。
|
||||
- **违反规则**:项目规则"可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock"。
|
||||
- **后果**:统计数据计算逻辑变更无回归保障;P3-16 弱项阈值修复这类边界 case 无法自动验证。
|
||||
|
||||
### 2.6 可复用性与扩展性
|
||||
|
||||
#### 问题 2.6.1 | 雷达图知识点名称截断无 tooltip(P2)
|
||||
|
||||
- **位置**:[mastery-radar-chart.tsx#L22-26](file:///e:/Desktop/CICD/src/modules/diagnostic/components/mastery-radar-chart.tsx#L22)
|
||||
- **现象**:`shortName: d.knowledgePoint.length > 8 ? ${d.knowledgePoint.slice(0, 8)}... : d.knowledgePoint` 截断超过 8 字符的知识点名称,但未提供 tooltip 显示完整名称。
|
||||
- **违反规则**:项目规则"明确处理空数据、无权限、网络异常等边界状态"——信息截断是边界状态。
|
||||
- **后果**:长名称知识点被截断,用户无法查看完整名称,影响数据理解。
|
||||
|
||||
#### 问题 2.6.2 | 通知类型使用 "grade" 而非专用类型(P2)
|
||||
|
||||
- **位置**:[actions.ts#L157, L173](file:///e:/Desktop/CICD/src/modules/diagnostic/actions.ts#L157)
|
||||
- **现象**:`createNotification({ type: "grade", ... })` 诊断报告发布通知复用了成绩通知类型 "grade"。
|
||||
- **违反规则**:项目规则精神——类型应语义准确,便于分类与过滤。
|
||||
- **后果**:学生无法区分"成绩通知"和"诊断报告通知";通知筛选功能无法按类型精确过滤诊断报告。
|
||||
|
||||
#### 问题 2.6.3 | 无监控埋点接口(P2)
|
||||
|
||||
- **位置**:整个模块无任何监控/埋点代码。
|
||||
- **现象**:报告生成、发布、删除、导出等关键操作无埋点。
|
||||
- **违反规则**:项目规则"监控:方案中预留关键操作埋点接口"。
|
||||
- **后果**:无法追踪诊断报告的使用情况;无法度量报告生成/发布转化率;异常无法主动发现。
|
||||
|
||||
### 2.7 parent 页面细节
|
||||
|
||||
#### 问题 2.7.1 | parent/diagnostic/page.tsx noRecordsTitle 与 noRecordsDescription 重复(P2)
|
||||
|
||||
- **位置**:[parent/diagnostic/page.tsx#L97-98](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/diagnostic/page.tsx#L97)
|
||||
- **现象**:`noRecordsTitle={t("parent.noReports")}` 和 `noRecordsDescription={t("parent.noReports")}` 使用同一个 i18n 键。
|
||||
- **原因**:复制粘贴时未区分标题和描述。
|
||||
- **后果**:空状态时标题和描述显示相同文本,UI 不够精细。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
对标 PowerSchool、Infinite Campus、Skyward、Alma、智学网、班级小管家、超星学习通、ClassIn 等 K12 系统,本模块在 v1/v4 修复后仍有以下差距:
|
||||
|
||||
| 维度 | 优秀实践 | 本模块现状 | 影响 |
|
||||
|------|---------|-----------|------|
|
||||
| **掌握度时间线** | PowerSchool/智学网支持掌握度时间线,展示知识点掌握度随时间的变化趋势,支持按知识点钻取历史 | 仅展示当前快照,`knowledgePointMastery` 表无历史版本,`lastAssessedAt` 仅记录最近一次 | 教师无法判断学生是否在进步或退步;无法评估教学干预效果 |
|
||||
| **个性化学习路径** | Alma/智学网基于弱项推荐具体学习资源(题目、视频、文档),形成学习路径 | 仅提供练习按钮跳转题目库,无资源推荐算法 | 推荐不够精准,学生需自行筛选练习内容 |
|
||||
| **学生×知识点掌握度矩阵** | Infinite Campus/Alma 提供学生×知识点矩阵热力图,支持点击单元格查看明细 | 班级视图仅有知识点聚合热力图,无学生维度矩阵 | 教师无法快速定位"哪个学生在哪个知识点上薄弱" |
|
||||
| **预测性分析(at-risk 预警)** | PowerSchool/Infinite Campus 基于历史数据预测 at-risk 学生,提前干预 | 无预测模型,仅基于当前掌握度 <60 判定需关注 | 无法主动预警,错失早期干预窗口 |
|
||||
| **报告模板自定义** | PowerSchool 支持学校自定义报告模板(推荐话术、评分区间、logo) | 报告内容固定由 stats-service 生成,仅 i18n 可切换语言 | 无法按学校需求定制报告风格 |
|
||||
| **多维度诊断** | Infinite Campus 结合成绩+出勤+行为做多维综合诊断 | 仅基于知识点掌握度单一维度 | 诊断维度单一,无法反映学生综合学习状态 |
|
||||
| **PDF 导出** | 所有对标系统支持 PDF 导出(便于打印分发) | 仅支持 Excel 导出 | 无法满足打印分发场景 |
|
||||
| **数据置信度可视化** | Alma 在报告上标注数据量与置信度,帮助教师判断结论可靠性 | 置信度计算过于简化(仅 null/非 null 两级) | 教师无法判断报告可信度 |
|
||||
| **流式渲染** | 现代 K12 系统使用 React Suspense 流式渲染,首屏快速可见 | 全部 `await Promise.all` 阻塞渲染 | 首屏白屏时间长 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响核心规范或安全)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| v2-P0-1 | 3 个子路由 error.tsx 硬编码中文 | 接入 `useTranslations("diagnostic")`,新增对应 i18n 键 |
|
||||
|
||||
### P1(重要,影响安全/解耦/一致性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| v2-P1-1 | grade_managed DataScope 未过滤 | 在 data-access-reports.ts 中处理 grade_managed scope,调用 `getUserIdsByGradeId` 过滤 |
|
||||
| v2-P1-2 | i18n 弱项标签与代码逻辑不一致 | 更新 i18n 标签为"弱项(<80%)" |
|
||||
| v2-P1-3 | 热力图 aria-label 硬编码中文标点 | 使用 i18n 模板键 `heatmapCellAriaLabel` |
|
||||
| v2-P1-4 | 组件直接 import actions 无接口抽象 | 定义 `DiagnosticService` 接口,通过 React Context 注入;组件通过 `useDiagnosticService()` 获取 |
|
||||
| v2-P1-5 | 置信度计算过于简化 | 基于 `totalQuestions` 实现多级置信度(<5 insufficient / 5-15 low / 16-30 medium / >30 high) |
|
||||
| v2-P1-6 | 无 Error Boundary 包裹独立数据区块 | 在概览、雷达图、强弱项、报告、历史等区块外包裹 WidgetBoundary |
|
||||
|
||||
### P2(增强,提升完整性与企业级能力)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| v2-P2-1 | 死 i18n 键未清理 | 移除 9 个 share 相关 i18n 键 |
|
||||
| v2-P2-2 | 教师页面客户端过滤 class_members | 在 data-access 层过滤,移除客户端 filter |
|
||||
| v2-P2-3 | export.ts 错误消息硬编码 | 改用 DiagnosticReportError 结构化错误码 |
|
||||
| v2-P2-4 | 异步数据无 Suspense 流式渲染 | 拆分数据获取为独立 async 组件,使用 Suspense 包裹(中长期) |
|
||||
| v2-P2-5 | 雷达图名称截断无 tooltip | 为截断的名称添加 Tooltip 显示完整名称 |
|
||||
| v2-P2-6 | 通知类型使用 "grade" | 新增 "diagnostic" 通知类型(需 notifications 模块配合) |
|
||||
| v2-P2-7 | 无监控埋点接口 | 定义 `DiagnosticMonitor` 接口,在关键操作处调用 |
|
||||
| v2-P2-8 | stats-service 无单测 | 为 14 个纯函数补充单元测试 |
|
||||
| v2-P2-9 | parent noRecordsTitle/Description 重复 | 区分标题和描述 i18n 键 |
|
||||
|
||||
### P3(长期,需较大投入)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| v2-P3-1 | 掌握度时间线 | 新增 `knowledgePointMasteryHistory` 表记录历史版本,前端展示时间线图表 |
|
||||
| v2-P3-2 | 个性化学习路径推荐 | 基于弱项推荐具体学习资源 |
|
||||
| v2-P3-3 | 学生×知识点掌握度矩阵 | 新增矩阵视图组件 |
|
||||
| v2-P3-4 | 预测性分析 | 基于 historical mastery 训练 at-risk 预测模型 |
|
||||
| v2-P3-5 | 报告模板自定义 | 允许学校配置报告模板 |
|
||||
| v2-P3-6 | PDF 导出 | 新增 PDF 导出能力 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次 v2 审计发现架构图需同步以下内容:
|
||||
|
||||
### 004_architecture_impact_map.md §2.22
|
||||
|
||||
1. **已知问题新增**:
|
||||
- 记录 v2-P0-1 三个子路由 error.tsx 硬编码中文及修复
|
||||
- 记录 v2-P1-1 grade_managed DataScope 未过滤及修复
|
||||
- 记录 v2-P1-4 组件解耦 Context 注入
|
||||
- 记录 v2-P1-5 置信度计算改进
|
||||
- 记录 v2-P1-6 数据区块 Error Boundary 包裹
|
||||
2. **依赖关系更新**:标注 diagnostic 模块新增 `DiagnosticServiceContext` 依赖注入机制
|
||||
|
||||
### 005_architecture_data.json
|
||||
|
||||
1. `modules.diagnostic.exports` 补充 `DiagnosticService` 接口、`DiagnosticServiceProvider`、`useDiagnosticService` hook
|
||||
2. `modules.diagnostic.knownIssues` 新增 v2 系列问题记录
|
||||
3. `modules.diagnostic.dependencies` 标注 grade_managed scope 现已调用 `getUserIdsByGradeId` 过滤
|
||||
|
||||
---
|
||||
|
||||
## 六、实施计划
|
||||
|
||||
### 6.1 本次实施范围(P0 + P1 + 可快速完成的 P2)
|
||||
|
||||
本次实施 **P0 全部 + P1 全部 + 6 项 P2**,P3 长期项记录备查不实施。
|
||||
|
||||
### 6.2 重构方案设计(满足强制原则)
|
||||
|
||||
#### 完全解耦
|
||||
|
||||
定义 TypeScript 接口 `DiagnosticService` 抽象所有数据依赖:
|
||||
|
||||
```typescript
|
||||
// src/modules/diagnostic/services/diagnostic-service.ts
|
||||
export interface DiagnosticService {
|
||||
generateStudentReport(studentId: string, period: string): Promise<string>
|
||||
generateClassReport(classId: string, period: string): Promise<string>
|
||||
generateGradeReport(gradeId: string, period: string): Promise<string>
|
||||
publishReport(id: string): Promise<void>
|
||||
deleteReport(id: string): Promise<void>
|
||||
exportReport(reportId: string): Promise<{ buffer: string; filename: string }>
|
||||
getClassStudentsByKp(classId: string, kpId: string, threshold?: number): Promise<...>
|
||||
}
|
||||
```
|
||||
|
||||
通过 React Context 注入:
|
||||
|
||||
```typescript
|
||||
// src/modules/diagnostic/services/diagnostic-service-context.tsx
|
||||
const DiagnosticServiceContext = createContext<DiagnosticService | null>(null)
|
||||
export function DiagnosticServiceProvider({ service, children }) { ... }
|
||||
export function useDiagnosticService(): DiagnosticService { ... }
|
||||
```
|
||||
|
||||
默认实现绑定现有 Server Actions;测试时可注入 mock 实现。
|
||||
|
||||
#### 组合优先
|
||||
|
||||
- `StudentDiagnosticView`、`ClassDiagnosticView`、`ReportList` 通过 `children` / slots 组合子区块。
|
||||
- 逻辑复用提取为 `useDiagnosticActions` hook(内部调用 `useDiagnosticService()`)。
|
||||
|
||||
#### 国际化就绪
|
||||
|
||||
- 所有新增文本使用 `diagnostic.*` 命名空间翻译键。
|
||||
- aria-label 模板使用 i18n 占位符:`heatmapCellAriaLabel: "{name}:{level}%,{label},{mastered}/{total}"`。
|
||||
|
||||
#### 最大化复用
|
||||
|
||||
- `DiagnosticSection` 通用区块组件(含 WidgetBoundary + Suspense + 标题 + 内容 slot)。
|
||||
- `useDiagnosticActions` hook 统一封装 actions 调用 + toast + router.refresh。
|
||||
|
||||
#### 错误与边界处理
|
||||
|
||||
- 每个数据区块外包裹 `WidgetBoundary`。
|
||||
- 异步子区块使用 `Suspense` + 骨架屏(本次仅在页面级流式拆分,组件级 Suspense 列入 P2-4 长期)。
|
||||
|
||||
#### 可测试性
|
||||
|
||||
- `DiagnosticService` 接口清晰,可 mock。
|
||||
- `confidence-utils` 置信度计算改为基于 `totalQuestions` 的多级判断。
|
||||
|
||||
#### 可扩展性
|
||||
|
||||
- 角色差异通过 `role-config.ts` 配置驱动(v4-P2-2 已实现)。
|
||||
- 通知类型、监控埋点通过接口预留扩展点。
|
||||
|
||||
#### 企业级补充
|
||||
|
||||
- a11y:热力图 aria-label i18n 化;雷达图 tooltip。
|
||||
- 性能:保持 RSC 获取初始数据。
|
||||
- 安全:grade_managed scope 在 data-access 层过滤。
|
||||
- 监控:定义 `DiagnosticMonitor` 接口预留埋点。
|
||||
|
||||
### 6.3 翻译文件结构示例
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostic": {
|
||||
"error": {
|
||||
"classLoadFailed": "班级学情诊断加载失败",
|
||||
"classLoadFailedDesc": "抱歉,加载班级诊断数据时发生了意外错误。请稍后重试。",
|
||||
"studentLoadFailed": "学生学情诊断加载失败",
|
||||
"studentLoadFailedDesc": "抱歉,加载学生诊断数据时发生了意外错误。请稍后重试。",
|
||||
"parentLoadFailed": "子女学情诊断加载失败",
|
||||
"parentLoadFailedDesc": "抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。",
|
||||
"retry": "重试"
|
||||
},
|
||||
"weaknesses": {
|
||||
"title": "弱项(<80%)"
|
||||
},
|
||||
"classDiagnostic": {
|
||||
"heatmapCellAriaLabel": "{name}:{level}%,{label},{mastered}/{total}"
|
||||
},
|
||||
"reportList": {
|
||||
"radarPointTooltip": "{fullName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
301
docs/architecture/audit/diagnostic-audit-report.md
Normal file
301
docs/architecture/audit/diagnostic-audit-report.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# 学情诊断(Diagnostic)模块审计报告
|
||||
|
||||
> 审计日期:2026-06-22
|
||||
> 审计范围:`src/modules/diagnostic/**`、`src/app/(dashboard)/teacher/diagnostic/**`、`src/app/(dashboard)/student/diagnostic/**`、`src/app/(dashboard)/parent/diagnostic/**`
|
||||
> 参照规则:`docs/architecture/004_architecture_impact_map.md` §2.22、`docs/architecture/005_architecture_data.json`、`.trae/rules/project_rules.md`
|
||||
> 前置文档:[grades-diagnostic-audit-report-v4.md](./grades-diagnostic-audit-report-v4.md)(v4 已完成 12 项 P1 数据安全修复)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| 类型 | [types.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/types.ts) | 109 | DiagnosticReport / Mastery / Summary 类型定义 |
|
||||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access.ts) | 477 | 掌握度查询 + 从提交/成绩更新掌握度 |
|
||||
| 数据访问 | [data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) | 256 | 诊断报告 CRUD + DataScope 过滤 |
|
||||
| 统计服务 | [stats-service.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/stats-service.ts) | 388 | 12 个纯统计函数 |
|
||||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/actions.ts) | 274 | 5 个 Action(生成/发布/删除/导出/按知识点筛选) |
|
||||
| 校验 | [schema.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/schema.ts) | 31 | 4 个 Zod schema |
|
||||
| 导出 | [export.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/export.ts) | 122 | Excel 导出 |
|
||||
| 组件 | [components/class-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx) | 448 | 班级诊断视图(热力图+筛选+排名+关注列表+生成) |
|
||||
| 组件 | [components/student-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/student-diagnostic-view.tsx) | 293 | 学生诊断视图(概览+雷达+强弱项+报告+历史) |
|
||||
| 组件 | [components/report-list.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/report-list.tsx) | 445 | 报告列表(过滤+表格+发布/删除/导出/分享) |
|
||||
| 组件 | [components/mastery-radar-chart.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/mastery-radar-chart.tsx) | 85 | 雷达图封装 |
|
||||
| 组件 | [components/confidence-utils.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/components/confidence-utils.ts) | 31 | 置信度计算 |
|
||||
| 页面 | [teacher/diagnostic/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/page.tsx) | 67 | 教师报告列表页 |
|
||||
| 页面 | [teacher/diagnostic/student/[studentId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx) | 86 | 教师查看学生诊断 |
|
||||
| 页面 | [teacher/diagnostic/class/[classId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx) | 50 | 教师班级诊断 |
|
||||
| 页面 | [student/diagnostic/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/diagnostic/page.tsx) | 40 | 学生自我诊断 |
|
||||
| 页面 | [parent/diagnostic/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/diagnostic/page.tsx) | 128 | 家长多子女诊断 |
|
||||
| 骨架屏 | 5 个 `loading.tsx` | — | 各路由骨架屏 |
|
||||
| 错误边界 | 5 个 `error.tsx` | — | 各路由错误边界 |
|
||||
| i18n | [diagnostic.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/diagnostic.json) | 204 | 中文翻译 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
├─ getStudentMasterySummary / getClassMasterySummary / getKnowledgePointStats (data-access)
|
||||
│ └─ db (drizzle) → knowledgePointMastery / knowledgePoints 表
|
||||
├─ getDiagnosticReports (data-access-reports, 含 DataScope 过滤)
|
||||
│ └─ db → learningDiagnosticReports 表
|
||||
└─ <StudentDiagnosticView> / <ClassDiagnosticView> / <ReportList> (client)
|
||||
└─ generateStudentReportAction / generateClassReportAction / publishReportAction / deleteReportAction / exportDiagnosticReportAction / getClassStudentsByKnowledgePointAction
|
||||
```
|
||||
|
||||
### 1.3 架构图记录完整性
|
||||
|
||||
经核对 [004_architecture_impact_map.md](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) §2.22 与 [005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json),架构图对诊断模块的记录**基本完整**,但存在以下偏差:
|
||||
|
||||
- 行数统计略有滞后:图记 `data-access.ts 179 行`,实际为 477 行(含 `updateMasteryFromHomeworkSubmission` 和 `updateMasteryFromExamScore` 两个大函数)。
|
||||
- 未记录 `export.ts` 的存在(架构图文件清单缺少此文件)。
|
||||
- 未记录 `confidence-utils.ts` 组件文件。
|
||||
- 未记录跨模块 UI 依赖:`teacher/diagnostic/student/[studentId]/page.tsx` 和 `teacher/diagnostic/class/[classId]/page.tsx` 直接 import `@/modules/grades/components/widget-boundary`,架构图未标注此跨模块 UI 依赖。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 架构解耦
|
||||
|
||||
#### 问题 2.1.1 | 跨模块直接 import UI 组件 WidgetBoundary(P0)
|
||||
|
||||
- **位置**:
|
||||
- [teacher/diagnostic/student/[studentId]/page.tsx#L13](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx#L13)
|
||||
- [teacher/diagnostic/class/[classId]/page.tsx#L8](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx#L8)
|
||||
- **现象**:`import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"`
|
||||
- **违反规则**:项目规则"模块间只能通过对方 data-access 通信,禁止跨模块直接查询数据库表"的精神延伸——UI 组件跨模块直接 import 同样破坏模块独立性。WidgetBoundary 是通用错误边界组件,不应属于 grades 业务模块。
|
||||
- **原因**:WidgetBoundary 最初为 grades 模块创建,diagnostic 模块复用时直接 import 了 grades 模块的实现,而非将其提升到 shared 层。
|
||||
- **后果**:grades 模块对 WidgetBoundary 的任何变更(重命名、删除、props 修改)都会破坏 diagnostic 模块编译;diagnostic 模块无法独立测试、独立部署。
|
||||
|
||||
#### 问题 2.1.2 | 组件直接 import actions,无服务接口抽象(P1)
|
||||
|
||||
- **位置**:
|
||||
- [components/report-list.tsx#L42](file:///e:/Desktop/CICD/src/modules/diagnostic/components/report-list.tsx#L42):`import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"`
|
||||
- [components/class-diagnostic-view.tsx#L33](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx#L33):`import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"`
|
||||
- **现象**:客户端组件直接 import 并调用 Server Actions,未通过接口抽象或依赖注入。
|
||||
- **违反规则**:项目规则"通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
|
||||
- **原因**:模块未采用依赖注入模式,组件与 actions 紧耦合。
|
||||
- **后果**:组件无法独立测试(测试时必须 mock 整个 actions 模块);无法在不修改组件代码的情况下替换 actions 实现。
|
||||
|
||||
### 2.2 国际化
|
||||
|
||||
#### 问题 2.2.1 | 教师页面标题硬编码英文(P0)
|
||||
|
||||
- **位置**:
|
||||
- [teacher/diagnostic/page.tsx#L59-62](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/page.tsx#L59):`<h1>Learning Diagnostic</h1>` + `<p>View and manage diagnostic reports based on knowledge point mastery.</p>`
|
||||
- [teacher/diagnostic/student/[studentId]/page.tsx#L70-74](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx#L70):`Student Diagnostic` + `Knowledge point mastery analysis and diagnostic reports.`
|
||||
- [teacher/diagnostic/class/[classId]/page.tsx#L38-42](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx#L38):`Class Diagnostic` + `Class-level knowledge point mastery overview and student attention list.`
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。
|
||||
- **原因**:教师端 3 个页面未使用 `getTranslations` 获取翻译,直接硬编码英文文案。学生端和家端已正确使用 i18n。
|
||||
- **后果**:中文环境下教师看到英文标题,与系统其他页面风格不一致。
|
||||
|
||||
#### 问题 2.2.2 | teacher/diagnostic/error.tsx 硬编码中文(P0)
|
||||
|
||||
- **位置**:[teacher/diagnostic/error.tsx#L17-22](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/error.tsx#L17)
|
||||
- **现象**:`title="学情诊断页面加载失败"` `description="抱歉,页面加载时发生了意外错误。请稍后重试。"` `label="重试"` 全部硬编码。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **原因**:error.tsx 是客户端组件但未使用 `useTranslations`。
|
||||
- **后果**:英文环境下错误页显示中文,国际化不一致。对比 student/diagnostic/error.tsx 已正确使用 i18n。
|
||||
|
||||
#### 问题 2.2.3 | parent/diagnostic/page.tsx 错误卡片中英文混用(P1)
|
||||
|
||||
- **位置**:[parent/diagnostic/page.tsx#L116-119](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/diagnostic/page.tsx#L116)
|
||||
- **现象**:`{t("error.loadFailed")} for {item.studentName}.` 和 `Please refresh the page or contact the school administrator if the problem persists.` 混用 i18n key 和硬编码英文。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **后果**:中文环境下显示"加载失败 for 张三.",中英文混杂,用户体验差。
|
||||
|
||||
#### 问题 2.2.4 | 报告内容硬编码中文(P1)
|
||||
|
||||
- **位置**:[stats-service.ts#L280-353](file:///e:/Desktop/CICD/src/modules/diagnostic/stats-service.ts#L280)
|
||||
- **现象**:`buildStudentReportContent` 和 `buildClassReportContent` 生成中文报告内容,如 `"建议复习「${m.knowledgePointName}」知识点"`、`"学生 ${summary.studentName} 在 ${period} 期间整体掌握度"` 等。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **原因**:纯函数层生成报告内容时直接硬编码中文,未通过 i18n。
|
||||
- **后果**:英文环境下生成的诊断报告内容为中文,无法国际化。报告内容存储在数据库中,已生成的历史报告无法回溯翻译。
|
||||
|
||||
#### 问题 2.2.5 | Excel 导出表头硬编码中文(P1)
|
||||
|
||||
- **位置**:[export.ts#L40-99](file:///e:/Desktop/CICD/src/modules/diagnostic/export.ts#L40)
|
||||
- **现象**:Excel 表头如 `"学生姓名"`、`"报告周期"`、`"综合得分"`、`"知识点掌握度"` 等硬编码中文;文件名 `诊断报告_${safePeriod}_${formatDateForFile()}.xlsx` 也硬编码。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **后果**:英文环境下导出的 Excel 文件表头和文件名为中文。
|
||||
|
||||
### 2.3 类型安全
|
||||
|
||||
#### 问题 2.3.1 | as 类型断言(P1)
|
||||
|
||||
- **位置**:[teacher/diagnostic/page.tsx#L24, L28](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/page.tsx#L24)
|
||||
- **现象**:`(v as DiagnosticReportType)` 和 `(v as DiagnosticReportStatus)` 使用 as 断言。
|
||||
- **违反规则**:项目规则"禁止 as 断言(除非从 unknown 转换或测试中,需注释原因)"。
|
||||
- **原因**:虽有类型守卫 `VALID_REPORT_TYPES.has(v)` 校验,但转换时使用了 as 而非类型守卫函数返回值收窄。
|
||||
- **后果**:绕过 TypeScript 严格类型检查,潜在类型不安全。
|
||||
|
||||
### 2.4 错误处理与边界
|
||||
|
||||
#### 问题 2.4.1 | 分享链接指向不存在的路由(P1)
|
||||
|
||||
- **位置**:[components/report-list.tsx#L157, L208](file:///e:/Desktop/CICD/src/modules/diagnostic/components/report-list.tsx#L157)
|
||||
- **现象**:`const url = \`${window.location.origin}/teacher/diagnostic/reports/${shareId}\`` 指向 `/teacher/diagnostic/reports/[id]` 路由,但该路由在项目中不存在(无对应 page.tsx)。
|
||||
- **原因**:分享功能开发时未创建对应路由页面。
|
||||
- **后果**:用户点击分享链接后得到 404 页面,功能不可用。
|
||||
|
||||
#### 问题 2.4.2 | 班级报告导出缺少明细(P2)
|
||||
|
||||
- **位置**:[export.ts#L85-113](file:///e:/Desktop/CICD/src/modules/diagnostic/export.ts#L85)
|
||||
- **现象**:班级报告仅导出概览 Sheet,缺少知识点统计和需关注学生明细。代码注释明确说明"班级报告的 studentId 为 null,需要从 period 反查 classId 不现实"。
|
||||
- **原因**:v4-P1 已为 `learningDiagnosticReports` 表新增 `classId` 字段,但 export.ts 未同步更新使用该字段查询班级明细。
|
||||
- **后果**:教师导出班级报告时只能看到概览,无法获取知识点统计和需关注学生列表,导出功能不完整。
|
||||
|
||||
### 2.5 可复用性与配置驱动
|
||||
|
||||
#### 问题 2.5.1 | 角色差异通过 props 硬编码而非配置驱动(P2)
|
||||
|
||||
- **位置**:[components/student-diagnostic-view.tsx#L32](file:///e:/Desktop/CICD/src/modules/diagnostic/components/student-diagnostic-view.tsx#L32)
|
||||
- **现象**:`practiceHrefBase` prop 区分角色(学生默认 `/student/learning/assignments`,教师传 `/teacher/questions`,家长传 `null`)。
|
||||
- **违反规则**:项目规则"采用配置驱动设计,例如通过角色配置决定该模块渲染哪些 Widget/子模块"。
|
||||
- **原因**:角色差异通过 props 传递,而非通过角色配置对象统一管理。
|
||||
- **后果**:新增角色需修改组件 props 传递逻辑,而非仅修改配置。
|
||||
|
||||
#### 问题 2.5.2 | 无年级诊断报告生成入口(P2)
|
||||
|
||||
- **位置**:[types.ts#L3](file:///e:/Desktop/CICD/src/modules/diagnostic/types.ts#L3)
|
||||
- **现象**:`DiagnosticReportType` 定义了 `"grade"` 类型,但 [actions.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/actions.ts) 无 `generateGradeReportAction`,[data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) 无 `generateGradeDiagnosticReport` 函数。
|
||||
- **原因**:年级报告功能定义了类型但未实现。
|
||||
- **后果**:管理员无法生成年级级别的诊断报告,功能不完整。report-list 过滤器中可选"年级"类型但永远无数据。
|
||||
|
||||
### 2.6 可访问性
|
||||
|
||||
#### 问题 2.6.1 | 热力图色块缺少键盘导航(P2)
|
||||
|
||||
- **位置**:[components/class-diagnostic-view.tsx#L190-201](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx#L190)
|
||||
- **现象**:热力图色块为 `<div>` 且仅有 `role="img"`,无 `tabIndex` 和键盘焦点样式,键盘用户无法逐个聚焦查看详情。
|
||||
- **违反规则**:项目规则"可访问性(a11y):语义化标签、ARIA 属性、键盘导航"。
|
||||
- **后果**:键盘用户无法通过 Tab 遍历热力图色块查看 tooltip/title 详情。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
对标 PowerSchool、Infinite Campus、Skyward、Alma、智学网、班级小管家等 K12 系统,本模块在以下方面存在差距:
|
||||
|
||||
| 维度 | 优秀实践 | 本模块现状 | 影响 |
|
||||
|------|---------|-----------|------|
|
||||
| **诊断趋势分析** | PowerSchool/智学网支持掌握度时间线,展示知识点掌握度随时间的变化趋势 | 仅展示当前快照,无历史趋势对比 | 教师无法判断学生是否在进步或退步 |
|
||||
| **年级诊断报告** | Infinite Campus 支持年级级别诊断,对比班级间差异 | 类型已定义但无实现入口 | 管理员无法做年级层面决策 |
|
||||
| **报告详情页** | 所有同类系统都有独立的报告详情页,支持分享链接 | 分享链接指向不存在的路由 | 分享功能不可用 |
|
||||
| **班级报告导出明细** | PowerSchool/Infinite Campus 导出含知识点统计+学生列表 | 班级报告仅导出概览 | 教师无法离线分析 |
|
||||
| **掌握度时间线** | 智学网展示每个知识点的掌握度变化曲线 | 无时间维度数据 | 无法评估教学干预效果 |
|
||||
| **个性化学习路径** | Alma/智学网基于弱项推荐学习路径和资源 | 仅提供练习按钮跳转题目库 | 推荐不够精准 |
|
||||
| **诊断报告模板** | PowerSchool 支持自定义报告模板 | 报告内容固定硬编码 | 无法按学校需求定制 |
|
||||
| **多维度诊断** | Infinite Campus 结合成绩+出勤+行为做多维诊断 | 仅基于知识点掌握度 | 诊断维度单一 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响功能正确性或核心规范)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | 跨模块 import WidgetBoundary | 将 WidgetBoundary 提升到 `shared/components/` 层,diagnostic 和 grades 模块统一从 shared 引用 |
|
||||
| P0-2 | 教师页面标题硬编码英文 | 3 个教师页面使用 `getTranslations("diagnostic")` 获取标题和描述 |
|
||||
| P0-3 | teacher/diagnostic/error.tsx 硬编码中文 | 接入 `useTranslations("diagnostic")`,与其他 error.tsx 一致 |
|
||||
|
||||
### P1(重要,影响用户体验或类型安全)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | parent 错误卡片中英文混用 | 提取完整 i18n 键,消除硬编码英文 |
|
||||
| P1-2 | as 类型断言 | 改用类型守卫函数返回值收窄,消除 as |
|
||||
| P1-3 | 分享链接指向不存在路由 | 移除分享功能或创建对应路由页面。鉴于当前无报告详情页需求,移除分享按钮避免 404 |
|
||||
| P1-4 | 报告内容硬编码中文 | stats-service 的报告内容生成改为接收 i18n 翻译函数参数,或在 actions 层调用时注入翻译后的模板 |
|
||||
| P1-5 | Excel 导出表头硬编码 | export.ts 接收 i18n 翻译参数,表头和文件名使用翻译键 |
|
||||
|
||||
### P2(增强,提升完整性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 班级报告导出缺少明细 | 利用 v4-P1 新增的 classId 字段查询班级掌握度,导出知识点统计+需关注学生 Sheet |
|
||||
| P2-2 | 角色差异通过 props 硬编码 | 定义角色配置对象,通过配置驱动 practiceHrefBase 等角色差异 |
|
||||
| P2-3 | 无年级诊断报告入口 | 实现 generateGradeDiagnosticReport + 对应 Action(中长期) |
|
||||
| P2-4 | 热力图色块缺少键盘导航 | 添加 tabIndex={0} 和 focus-visible 样式 |
|
||||
| P2-5 | 架构图行数统计滞后 | 同步 data-access.ts 实际行数,补充 export.ts 和 confidence-utils.ts 记录 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图需同步以下内容:
|
||||
|
||||
### 004_architecture_impact_map.md §2.22
|
||||
|
||||
1. **文件清单更新**:
|
||||
- `data-access.ts` 行数从 179 更新为 477(含 `updateMasteryFromHomeworkSubmission` 和 `updateMasteryFromExamScore`)
|
||||
- 补充 `export.ts`(122 行,Excel 导出)
|
||||
- 补充 `components/confidence-utils.ts`(31 行,置信度计算)
|
||||
2. **已知问题新增**:
|
||||
- 记录 P0-1 跨模块 import WidgetBoundary 问题及修复
|
||||
- 记录 P0-2/P0-3 i18n 遗漏问题及修复
|
||||
- 记录 P1-3 分享链接 404 问题及修复
|
||||
3. **依赖关系更新**:标注 WidgetBoundary 已从 grades 模块提升到 shared 层
|
||||
|
||||
### 005_architecture_data.json
|
||||
|
||||
1. `modules.diagnostic.exports` 补充 `export.ts` 和 `confidence-utils.ts` 文件记录
|
||||
2. `modules.diagnostic.dependencies` 更新:移除对 `grades/components/widget-boundary` 的 UI 依赖,改为 `shared/components/widget-boundary`
|
||||
3. `modules.diagnostic.fileList` 行数同步更新
|
||||
|
||||
---
|
||||
|
||||
## 六、实施状态(2026-06-24 全部完成)
|
||||
|
||||
> 本章节记录审计报告中所有 P0/P1/P2 项的实施完成情况。所有项均已通过 `npx tsc --noEmit` 与 `npm run lint` 校验(诊断模块零错误)。
|
||||
|
||||
### 6.1 P0 项实施状态
|
||||
|
||||
| 编号 | 状态 | 实施内容 | 涉及文件 |
|
||||
|------|------|---------|---------|
|
||||
| P0-1 | ✅ 已完成 | WidgetBoundary 已从 `modules/grades/components/widget-boundary.tsx` 提升到 `shared/components/widget-boundary.tsx`;diagnostic 与 grades 模块统一从 `@/shared/components/widget-boundary` 引用;grades 模块原文件已删除 | `src/shared/components/widget-boundary.tsx`(新建)、`src/modules/grades/components/widget-boundary.tsx`(删除)、`src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx`、`src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx` |
|
||||
| P0-2 | ✅ 已完成 | 3 个教师页面(`teacher/diagnostic/page.tsx`、`teacher/diagnostic/student/[studentId]/page.tsx`、`teacher/diagnostic/class/[classId]/page.tsx`)均使用 `getTranslations("diagnostic")` 获取标题与描述,新增对应 i18n 键 `teacherTitle`、`teacherDescription`、`studentTitle`、`studentDescription`、`classTitle`、`classDescription` | 上述 3 个页面 + `src/shared/i18n/messages/zh-CN/diagnostic.json` + `src/shared/i18n/messages/en/diagnostic.json` |
|
||||
| P0-3 | ✅ 已完成 | `teacher/diagnostic/error.tsx` 接入 `useTranslations("diagnostic")`,与 `student/diagnostic/error.tsx` 风格一致;新增 i18n 键 `errorTitle`、`errorDescription`、`errorRetry` | `src/app/(dashboard)/teacher/diagnostic/error.tsx` + 两个 i18n 文件 |
|
||||
|
||||
### 6.2 P1 项实施状态
|
||||
|
||||
| 编号 | 状态 | 实施内容 | 涉及文件 |
|
||||
|------|------|---------|---------|
|
||||
| P1-1 | ✅ 已完成 | `parent/diagnostic/page.tsx` 错误卡片中英文混用已消除;新增 i18n 键 `errorForStudent`、`errorContactAdmin`,使用 `t("errorForStudent", { name: item.studentName })` 替代硬编码 | `src/app/(dashboard)/parent/diagnostic/page.tsx` + 两个 i18n 文件 |
|
||||
| P1-2 | ✅ 已完成 | `teacher/diagnostic/page.tsx` 中 `(v as DiagnosticReportType)` 和 `(v as DiagnosticReportStatus)` 已替换为类型守卫函数返回值收窄:`VALID_REPORT_TYPES.has(v) ? v : DEFAULT_REPORT_TYPE` 模式,消除 `as` 断言 | `src/app/(dashboard)/teacher/diagnostic/page.tsx` |
|
||||
| P1-3 | ✅ 已完成 | `components/report-list.tsx` 中分享按钮与相关逻辑已移除(包括 `shareReportAction` 调用、`window.location.origin` URL 构造、分享对话框),避免 404;保留发布/删除/导出三个核心操作 | `src/modules/diagnostic/components/report-list.tsx` |
|
||||
| P1-4 | ✅ 已完成 | `stats-service.ts` 中 `buildStudentReportContent` 和 `buildClassReportContent` 已重构为接收 `ReportContentTranslations` 接口参数;新增 `getReportContentTranslations()` 在 data-access-reports 层调用 `getTranslations` 注入翻译;报告内容生成改为 i18n 驱动 | `src/modules/diagnostic/stats-service.ts`、`src/modules/diagnostic/data-access-reports.ts`、两个 i18n 文件 |
|
||||
| P1-5 | ✅ 已完成 | `export.ts` 中 Excel 表头和文件名已改为接收 i18n 翻译参数;`exportDiagnosticReportAction` 在调用 `exportDiagnosticReportToExcel` 前通过 `getTranslations("diagnostic")` 注入翻译;新增 i18n 键 `sheetOverview`、`sheetClassStats`、`sheetAttentionStudents`、`colStudentName`、`colPeriod`、`colReportType`、`colStatus`、`colScore`、`colGeneratedAt`、`colSummary`、`colStrengths`、`colWeaknesses`、`colRecommendations`、`filenameDiagnosticReport` 等 | `src/modules/diagnostic/export.ts`、`src/modules/diagnostic/actions.ts`、两个 i18n 文件 |
|
||||
|
||||
### 6.3 P2 项实施状态
|
||||
|
||||
| 编号 | 状态 | 实施内容 | 涉及文件 |
|
||||
|------|------|---------|---------|
|
||||
| P2-1 | ✅ 已完成 | 班级报告导出已利用 v4-P1 新增的 `classId` 字段调用 `getClassMasterySummary`,导出包含三个 Sheet:概览、知识点统计、需关注学生明细;新增 i18n 键 `sheetClassStats`、`sheetAttentionStudents`、`metricClass`、`metricStudentCount`、`metricAttentionCount`、`colMasteredCount`、`colNotMasteredCount`、`colTotalStudents`、`colAverageMastery`、`colWeakCount`、`noAttentionStudents` | `src/modules/diagnostic/export.ts` + 两个 i18n 文件 |
|
||||
| P2-2 | ✅ 已完成 | 新建 `src/modules/diagnostic/role-config.ts`,定义 `DiagnosticRole` 类型、`DiagnosticRoleConfig` 接口、`DIAGNOSTIC_ROLE_CONFIG` 记录(student/teacher/parent 三角色配置)和 `getDiagnosticRoleConfig` 辅助函数;`StudentDiagnosticView` 组件新增 `role` prop,内部通过 `getDiagnosticRoleConfig(role).practiceHrefBase` 解析配置;原 `practiceHrefBase` prop 标记 `@deprecated` 保留向后兼容(同时传入时 `role` 优先);3 个调用点已迁移为 `role="student"` / `role="teacher"` / `role="parent"` | `src/modules/diagnostic/role-config.ts`(新建)、`src/modules/diagnostic/components/student-diagnostic-view.tsx`、`src/app/(dashboard)/student/diagnostic/page.tsx`、`src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx`、`src/app/(dashboard)/parent/diagnostic/page.tsx` |
|
||||
| P2-3 | ✅ 已完成 | 完整实现年级诊断报告纵向切片:① DB schema 新增 `gradeId` 字段 + `gradeIdx` 索引 + 迁移 SQL `0012_diagnostic_grade_id.sql`;② 类型新增 `GradeMasterySummary` 接口,`DiagnosticReport` 接口新增 `gradeId: string \| null`;③ data-access 新增 `getGradeMasterySummary`(缓存,并行查询年级名+学生 ID+掌握度行);④ stats-service 新增 `buildGradeMasterySummary` 和 `buildGradeReportContent` 纯函数;⑤ data-access-reports 新增 `generateGradeDiagnosticReport`,含 `GRADE_NOT_FOUND` / `GRADE_NO_MASTERY_DATA` 错误码;⑥ schema 新增 `GenerateGradeReportSchema`;⑦ actions 新增 `generateGradeReportAction` Server Action(含 `requirePermission` + `revalidatePath`);⑧ i18n 新增 `gradeSummary`、`gradeRecommendation`、`gradeNoWeakness` 键 | `src/shared/db/schema.ts`、`drizzle/0012_diagnostic_grade_id.sql`(新建)、`src/modules/diagnostic/types.ts`、`src/modules/diagnostic/data-access.ts`、`src/modules/diagnostic/stats-service.ts`、`src/modules/diagnostic/data-access-reports.ts`、`src/modules/diagnostic/schema.ts`、`src/modules/diagnostic/actions.ts`、两个 i18n 文件 |
|
||||
| P2-4 | ✅ 已完成 | 班级诊断视图热力图色块新增 `tabIndex={0}` 和 `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` 样式;外层容器 `role` 从 `"img"` 改为 `"group"`(因容器内现含可聚焦元素);保留每个色块的 `aria-label` 提供完整描述 | `src/modules/diagnostic/components/class-diagnostic-view.tsx` |
|
||||
| P2-5 | ✅ 已完成 | 架构图 004 和 005 已同步:① `data-access.ts` 行数更新为实际值;② 补充 `export.ts`、`role-config.ts`、`confidence-utils.ts` 文件记录;③ 已知问题章节新增 11 条 P0-1 至 P2-4 修复记录;④ 依赖矩阵新增 `school` 模块依赖(`getGradeNameById`、`getUserIdsByGradeId`)和 `shared/components/widget-boundary`;⑤ `learningDiagnosticReports` 表描述补充 `gradeId` 字段;⑥ `modules.diagnostic.exports` 新增 `getGradeMasterySummary`、`generateGradeDiagnosticReport`、`buildGradeMasterySummary`、`buildGradeReportContent`、`generateGradeReportAction`、`GenerateGradeReportSchema` 等 | `docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json` |
|
||||
|
||||
### 6.4 验证结果
|
||||
|
||||
- **TypeScript**:`npx tsc --noEmit` 通过,诊断模块零错误(仅 `dashboard/services/dashboard-service.ts` 存在与本模块无关的预存语法错误)。
|
||||
- **ESLint**:`npm run lint` 通过,诊断模块零警告。
|
||||
- **架构图一致性**:004 与 005 两份架构文档已与源码同步,所有新增/修改的导出函数、类型、依赖关系、DB 表字段均已记录。
|
||||
|
||||
### 6.5 后续建议(未列入本次实施范围)
|
||||
|
||||
以下为审计过程中识别但未列入本次实施的长期增强项,建议后续按需推进:
|
||||
|
||||
1. **掌握度时间线**:新增 `knowledgePointMasteryHistory` 表记录每次掌握度变化,前端展示时间线图表。
|
||||
2. **个性化学习路径推荐**:基于弱项知识点推荐具体学习资源(题目、视频、文档),而非仅跳转题目库。
|
||||
3. **多维度诊断**:结合成绩、出勤、行为数据做多维综合诊断。
|
||||
4. **报告模板自定义**:允许学校配置报告内容模板(如自定义推荐话术、评分区间)。
|
||||
5. **报告详情页**:若未来需要分享功能,创建 `/teacher/diagnostic/reports/[id]` 路由页面。
|
||||
6. **可测试性增强**:为 `stats-service.ts` 中 12 个纯函数补充单元测试,导出 `ReportContentTranslations` 接口便于 mock。
|
||||
7. **依赖注入抽象**:将 `report-list.tsx` 和 `class-diagnostic-view.tsx` 中直接 import actions 的模式重构为通过 React Context 注入数据服务接口,提升可测试性。
|
||||
339
docs/architecture/audit/elective-audit-report.md
Normal file
339
docs/architecture/audit/elective-audit-report.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# 选修课(Elective)模块审计报告
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:
|
||||
> - `src/modules/elective/**`
|
||||
> - `src/app/(dashboard)/admin/elective/**`、`src/app/(dashboard)/teacher/elective/**`、`src/app/(dashboard)/student/elective/**`
|
||||
> - 跨模块依赖:`school` / `users` / `classes` 的 data-access,`rbac` 的 `ELECTIVE_*` 权限点
|
||||
> - i18n 资源:`src/shared/i18n/messages/{zh-CN,en}/elective.json`
|
||||
> 参照规则:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`.trae/rules/project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/elective/actions.ts) | 348 | 8 个写 Action(权限校验 + Zod + trackEvent + 资源归属校验) |
|
||||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts) | 258 | 课程 CRUD + scope 过滤 + 显示名聚合 + 共享映射函数 |
|
||||
| 数据访问 | [data-access-operations.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts) | 374 | 选课/退课/抽签(事务 + FOR UPDATE 锁 + Fisher-Yates) + 时间冲突/学分上限校验 |
|
||||
| 数据访问 | [data-access-selections.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-selections.ts) | 147 | 选课记录查询 + 学生可选课程 |
|
||||
| 跨模块抽象 | [resolvers.ts](file:///e:/Desktop/CICD/src/modules/elective/resolvers.ts) | 83 | CourseDisplayResolver / StudentGradeResolver 接口 + 注入函数(测试 mock 友好) |
|
||||
| Schema | [schema.ts](file:///e:/Desktop/CICD/src/modules/elective/schema.ts) | 179 | Zod 校验(5 个 schema) |
|
||||
| Types | [types.ts](file:///e:/Desktop/CICD/src/modules/elective/types.ts) | 73 | 类型定义 |
|
||||
| Constants | [constants.ts](file:///e:/Desktop/CICD/src/modules/elective/constants.ts) | 55 | i18n key 映射 + Badge variant + 类型守卫 |
|
||||
| Import-export | [export.ts](file:///e:/Desktop/CICD/src/modules/elective/export.ts) | 102 | Excel 导出(课程列表 + 选课名单) |
|
||||
| 组件 | [components/elective-page-layout.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-page-layout.tsx) | 30 | 页面布局骨架(header/children 插槽) |
|
||||
| 组件 | [components/elective-course-list.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx) | 236 | 课程卡片网格 + 管理操作 |
|
||||
| 组件 | [components/elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx) | 301 | 课程创建/编辑表单 |
|
||||
| 组件 | [components/elective-filters.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-filters.tsx) | 51 | nuqs 筛选栏(搜索 + 模式) |
|
||||
| 组件 | [components/student-selection-view.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/student-selection-view.tsx) | 248 | 学生选课视图(已选 + 可选) |
|
||||
| 页面 | [admin/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx) | 47 | 管理员课程列表(RSC) |
|
||||
| 页面 | [admin/elective/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/create/page.tsx) | 32 | 创建课程(RSC) |
|
||||
| 页面 | [admin/elective/[id]/edit/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx) | 44 | 编辑课程(RSC) |
|
||||
| 页面 | [teacher/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx) | 58 | 教师我的课程(RSC) |
|
||||
| 页面 | [student/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/elective/page.tsx) | 48 | 学生选课中心(RSC) |
|
||||
| 骨架屏 | 5 个 `loading.tsx`(admin/admin-create/admin-edit/teacher/student) | — | 列表/表单骨架屏 |
|
||||
| 错误边界 | 3 个 `error.tsx`(admin/teacher/student) | — | 错误兜底 |
|
||||
| i18n | [zh-CN/elective.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/elective.json) | 114 | 中文翻译 |
|
||||
| i18n | [en/elective.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/en/elective.json) | 114 | 英文翻译 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
└─ getElectiveCourses / getElectiveCourseById / getAvailableCoursesForStudent / getStudentSelections (data-access)
|
||||
└─ db (drizzle) → electiveCourses / courseSelections 表
|
||||
└─ 跨模块 data-access(通过 resolvers.ts 接口抽象):
|
||||
school.getSubjectOptions / school.getGradeOptions
|
||||
users.getUserNamesByIds
|
||||
classes.getStudentActiveGradeId
|
||||
└─ <ElectiveCourseList> (client) → deleteElectiveCourseAction / openSelectionAction / closeSelectionAction / runLotteryAction
|
||||
└─ <ElectiveCourseForm> (client) → createElectiveCourseAction / updateElectiveCourseAction
|
||||
└─ <StudentSelectionView> (client) → selectCourseAction / dropCourseAction
|
||||
```
|
||||
|
||||
### 1.3 架构图记录完整性
|
||||
|
||||
`docs/architecture/004_architecture_impact_map.md` 与 `005_architecture_data.json` 已覆盖 elective 模块(章节 2.20),包含完整的 exports / 依赖关系 / 权限点 / 文件清单。但「已知问题」段存在过时信息(详见第五部分),需同步修正。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 P0:教师页面跳转到 admin 路由(跨角色越权 + 404)
|
||||
|
||||
- **位置**:[teacher/elective/page.tsx:53-54](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx#L53-L54)
|
||||
- **现象**:教师页面渲染 `ElectiveCourseList` 时传入 `createHref="/admin/elective/create"`、`editBaseHref="/admin/elective"`。教师点击"创建课程"或"编辑"按钮后会被路由到 `/admin/*`,由于中间件对 admin 角色做了路由保护,教师实际看到的是 403 / 重定向到首页。
|
||||
- **原因**:`ElectiveCourseList` 只支持单一 `createHref`/`editBaseHref`,admin 与 teacher 共用一份组件时硬编码了 admin 路径。
|
||||
- **违反规则**:「Server Action 必须使用 `requirePermission()` 进行权限校验」(前端入口虽然校验了权限,但跳转到无权访问的路由等同于绕过校验);UX 上不可达。
|
||||
- **后果**:教师角色虽然被授予 `ELECTIVE_MANAGE` 权限,却无法实际创建/编辑课程,功能完全不可用。
|
||||
|
||||
### 2.2 P0:parent 角色缺失选课页面
|
||||
|
||||
- **位置**:`src/app/(dashboard)/parent/elective/**`(目录不存在)
|
||||
- **现象**:[005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) 中 parent 角色被授予 `ELECTIVE_READ` 权限,但没有对应的 parent 页面。家长无法查看子女的选课情况。
|
||||
- **违反规则**:「所有用户可见文本必须适配 i18n」「最大化复用:识别四个角色共用的 UI 块和业务逻辑块」——当前只覆盖 3 个角色,遗漏 parent。
|
||||
- **后果**:家长对子女选课缺乏监督,无法及时发现选课异常(如未选满学分、错选时间冲突课程)。
|
||||
|
||||
### 2.3 P0:admin/student 页面缺少 `requirePermission()`
|
||||
|
||||
- **位置**:
|
||||
- [admin/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx)(无任何权限校验,直接调用 `getElectiveCourses`)
|
||||
- [student/elective/page.tsx:17](file:///e:/Desktop/CICD/src/app/(dashboard)/student/elective/page.tsx#L17) 使用 `getAuthContext()` 仅获取 userId,未做权限校验
|
||||
- **违反规则**:「Server Action 必须使用 `requirePermission()` 进行权限校验」 + 项目记忆中的硬约束「Parent routes must include permission checks with both `parentId` and `studentId` to prevent information leakage」。
|
||||
- **后果**:仅依赖中间件的路由级保护,缺少纵深防御;若中间件配置出现疏漏(如新增动态路由),会直接导致越权读他人数据。teacher 页面已经做了示范(`requirePermission(Permissions.ELECTIVE_READ)`),admin/student 不应例外。
|
||||
|
||||
### 2.4 P0:选课/退课错误消息未走 i18n
|
||||
|
||||
- **位置**:[data-access-operations.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts)
|
||||
- L233 `throw new Error("Course selection is not open")`
|
||||
- L237 `throw new Error("Selection has not started yet")`
|
||||
- L241 `throw new Error("Selection has ended")`
|
||||
- L254 `throw new Error("Already selected this course")`
|
||||
- L259 `throw new Error("Schedule conflicts with your existing courses")`
|
||||
- L265 `throw new Error(\`Credit limit exceeded (${creditCheck.current}/${creditCheck.max})\`)`
|
||||
- L301-303 `message: "Enrolled successfully" / "Added to waitlist" / "Selection submitted"`
|
||||
- **现象**:上述英文 throw 出去后经由 `handleActionError` 包装为 `{ success: false, message: <英文> }` 返回前端,toast 直接显示英文。
|
||||
- **违反规则**:「所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键」。
|
||||
- **后果**:中文用户看到英文错误提示,i18n 资源中已存在对应的中文键(`errors.selectionClosed`、`errors.alreadySelected`、`errors.scheduleConflict`、`errors.creditExceeded` 等)却完全没被复用。
|
||||
|
||||
### 2.5 P0:`export.ts` 存在 `as` 类型断言
|
||||
|
||||
- **位置**:[export.ts:21](file:///e:/Desktop/CICD/src/modules/elective/export.ts#L21)
|
||||
```ts
|
||||
status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined,
|
||||
```
|
||||
- **违反规则**:「禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)」。
|
||||
- **后果**:未做类型守卫即强转,传入非法字符串(如 `"foo"`)会被静默接受,运行时引发 SQL 类型不匹配。
|
||||
|
||||
### 2.6 P1:`elective-course-form.tsx` 大量硬编码英文文案
|
||||
|
||||
- **位置**:[elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx)
|
||||
- L95 `"New Elective Course"` / `"Edit Elective Course"`
|
||||
- L102 `"Course Name *"`
|
||||
- L112 `"Subject"`
|
||||
- L129 `"Grade"`
|
||||
- L146 `"Teacher"`
|
||||
- L163 `"Capacity"`
|
||||
- L175 `"Classroom"`
|
||||
- L184 `"Schedule"`
|
||||
- L188 `placeholder="e.g. Mon 14:00-15:30"`
|
||||
- L194 `"Credit"`
|
||||
- L225 `"Start Date"`
|
||||
- L235 `"End Date"`
|
||||
- L245 `"Selection Start"`
|
||||
- L258 `"Selection End"`
|
||||
- L274 `"Description"`
|
||||
- L278 `placeholder="Course description..."`
|
||||
- L291 `"Cancel"`
|
||||
- L294 `"Saving..."` / `"Create"` / `"Save"`
|
||||
- L72-85 `"Invalid form state"` / `"Failed to save course"` 等 toast 回退文案
|
||||
- **违反规则**:「所有用户可见文本必须适配 i18n」+ i18n 资源已存在 `form.createTitle` / `form.editTitle` / `form.namePlaceholder` / `form.descriptionPlaceholder` 等键。
|
||||
- **后果**:中文用户在创建/编辑课程表单中看到全英文界面,体验割裂。
|
||||
|
||||
### 2.7 P1:`elective-course-list.tsx` 与 `elective-course-form.tsx` 使用 `<a>` 而非 `<Link>`
|
||||
|
||||
- **位置**:
|
||||
- [elective-course-list.tsx:92](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx#L92) `<a href={createHref}>`
|
||||
- [elective-course-list.tsx:177](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx#L177) `<a href={...edit...}>`
|
||||
- **违反规则**:项目记忆「Link navigation must use Next.js `<Link>` component instead of raw `<a>` tags」。
|
||||
- **后果**:原生 `<a>` 触发整页刷新,丢失客户端导航状态、prefetch 优化、Layout 复用。
|
||||
|
||||
### 2.8 P1:错误边界文案与按钮文案错误
|
||||
|
||||
- **位置**:[admin/elective/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/error.tsx) 与 [teacher/elective/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/error.tsx)
|
||||
- **现象**:
|
||||
- `title` 与 `description` 都用 `t("errors.unexpected")`,重复且无信息量;
|
||||
- 重试按钮 `action.label` 用 `t("actions.save")`("保存"),但学生页用 `t("actions.retry")`("重试")—— admin/teacher 文案错误。
|
||||
- **违反规则**:i18n 完整性 + UX 一致性。
|
||||
- **后果**:用户在错误页看到"保存"按钮且语义与"重试"不符。
|
||||
|
||||
### 2.9 P1:表单未使用 React Hook Form
|
||||
|
||||
- **位置**:[elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx)
|
||||
- **现象**:使用 4 个独立 `useState` 管理 Select 状态,没有统一表单状态管理、字段校验、脏值检查。项目 tech stack 明确包含 `React Hook Form`。
|
||||
- **违反规则**:技术栈一致性 + 可维护性。
|
||||
- **后果**:字段一多需要每个都加 `useState`,扩展性差;与服务端 Zod 校验形成两套校验,难以保持一致。
|
||||
|
||||
### 2.10 P1:`export.ts` 表头混用英文硬编码
|
||||
|
||||
- **位置**:[export.ts:56](file:///e:/Desktop/CICD/src/modules/elective/export.ts#L56) `"Status"`、L93 `"Status"`、L94 `"Priority"`、L95 `"Selected At"`、L96 `"Enrolled At"`
|
||||
- **现象**:课程列表 sheet 中 `status` 列的 header 是英文硬编码 `"Status"`,而其他列都走 i18n;选课名单 sheet 中 `status`/`priority`/`selectedAt`/`enrolledAt` 4 列 header 全英文硬编码。
|
||||
- **违反规则**:「所有用户可见文本必须适配 i18n」—— Excel 导出也是用户可见文本。
|
||||
- **后果**:中文用户下载 Excel 后表头混杂中英文。
|
||||
|
||||
### 2.11 P1:`getElectiveCourses` 静默吞错
|
||||
|
||||
- **位置**:[data-access.ts:161-164](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts#L161-L164)
|
||||
```ts
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourses failed:", error)
|
||||
return []
|
||||
}
|
||||
```
|
||||
- **现象**:DB 查询失败时返回空数组,页面无任何错误提示,用户以为"暂无数据"。
|
||||
- **违反规则**:「错误与边界处理:明确处理空数据、无权限、网络异常等边界状态」。
|
||||
- **后果**:DB 异常被掩盖,运维无法及时发现,用户误判为"无课程",无法触发错误边界。
|
||||
|
||||
### 2.12 P1:`parseSchedule` 不支持完整英文星期与多时段
|
||||
|
||||
- **位置**:[data-access-operations.ts:35](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L35)
|
||||
```ts
|
||||
const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+.../)
|
||||
```
|
||||
- **现象**:
|
||||
- 字符类 `[MonTueWedThuFriSatSun]+` 匹配任意 M/o/n/T/u/e 字符组合(如 `"Mon"`、`"oMenT"` 都会通过),不严谨;
|
||||
- 不支持 `"Monday"`、`"周一 14:00-15:30, 周三 16:00-17:30"` 多时段;
|
||||
- `normalizeDay` 表只有 `mon/tue/...`,没有 `monday/tuesday/...`。
|
||||
- **后果**:教师在 schedule 输入 `"Monday 14:00-15:30"` 时不会触发冲突检测,存在隐性排课冲突。
|
||||
|
||||
### 2.13 P1:学分上限与候补人数硬编码(✅ 2026-06-25 已修复)
|
||||
|
||||
- **位置**:[data-access-operations.ts:15](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L15) `const MAX_CREDIT_PER_TERM = 10`
|
||||
- **现象**:所有年级/学校共用同一个上限,无法配置。
|
||||
- **违反规则**:「可扩展性:采用配置驱动设计」。
|
||||
- **后果**:K12 不同年级(如高一 8 学分 vs 高三 12 学分)无法差异化配置。
|
||||
- **修复说明**:新增 [data-access-settings.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-settings.ts),复用 `systemSettings` 表(category="elective")作为配置存储。`getElectiveCreditLimit(gradeId?)` 支持按年级覆盖(key=`creditLimit:grade:<gradeId>`),fallback 到全局(key=`creditLimit:default`),均未配置返回默认值 10。React `cache()` 包装请求级去重。`checkCreditLimit` 新增 `studentGradeId: string | null` 参数并调用 `getElectiveCreditLimit(studentGradeId)`,`selectCourse` 在事务前通过 `getStudentGradeId(studentId)` 拿到年级 ID 并透传。
|
||||
|
||||
### 2.14 P1:抽签结果不可重跑
|
||||
|
||||
- **位置**:[data-access-operations.ts:212](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L212)
|
||||
```ts
|
||||
await tx.update(electiveCourses).set({ enrolledCount, status: "closed", updatedAt: now })...
|
||||
```
|
||||
- **现象**:抽签完成立即把课程状态置为 `closed`,管理员若发现结果异常无法重新抽签(重抽需要先把状态手动改回 `open`)。
|
||||
- **后果**:管理员缺乏"试抽 + 调整 + 正式抽"的灵活度,K12 学校在抽签争议时无法快速复核。
|
||||
|
||||
### 2.15 P1:管理员缺少课程统计概览
|
||||
|
||||
- **位置**:缺失
|
||||
- **现象**:admin/teacher 页面只有平铺的课程卡片,缺少总览统计(如总课程数、总选课人数、热门科目分布、容量使用率)。
|
||||
- **违反规则**:行业最佳实践「K12 admin 应有数据驾驶舱」。
|
||||
- **后果**:管理员难以从全局角度掌握选课运行情况。
|
||||
|
||||
### 2.16 P2:`getCachedSubjectOptions` / `getCachedGradeOptions` 缓存全量选项
|
||||
|
||||
- **位置**:[data-access.ts:104-105](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts#L104-L105)
|
||||
- **现象**:即使只需要 1 个科目的名称,也会拉取全部 subject/grade 选项。
|
||||
- **后果**:高并发场景下存在 N+1 缓存膨胀风险(虽 React `cache()` 限单次请求内,但单次请求若涉及 1000+ 课程仍冗余)。
|
||||
|
||||
### 2.17 P2:缺少 Suspense 流式渲染(✅ 2026-06-25 已修复)
|
||||
|
||||
- **位置**:所有 RSC 页面
|
||||
- **现象**:admin/teacher/student 页面用 `Promise.all` 一次性等待所有数据,没有 `<Suspense>` 边界,无法流式渲染局部内容。
|
||||
- **违反规则**:「异步数据使用 React Suspense + 骨架屏」「性能:支持流式渲染」。
|
||||
- **后果**:单条慢查询会拖累整页加载时间,用户长时间看到白屏。
|
||||
- **修复说明**:
|
||||
- student 页面拆分为 `MySelectionsLoader`(Suspense)+ `AvailableCoursesLoader`(Suspense),分别对应"我的选课"与"可选课程"
|
||||
- admin 页面拆分为 `StatsCardsLoader`(Suspense)+ `CourseListLoader`(Suspense),统计卡片与列表分离
|
||||
- `StudentSelectionView` 拆分为 `StudentMySelectionsSection` + `StudentAvailableCoursesSection` 两个独立客户端组件
|
||||
- `loading.tsx` 新增 `MySelectionsSkeleton` / `AvailableCoursesSkeleton` / `StatsCardsSkeleton` / `CourseListSkeleton` 分段骨架屏
|
||||
- React `cache()` 自动去重 `getStudentSelections` 调用,两个 Suspense 边界共享同一份数据
|
||||
|
||||
### 2.18 P2:缺少课程先修/容量阈值通知(✅ 2026-06-25 部分修复)
|
||||
|
||||
- **位置**:缺失
|
||||
- **现象**:K12 学校通常需要:
|
||||
- 课程先修要求(如"必须先选 Python 入门才能选 Python 进阶");
|
||||
- 容量阈值通知(如课程满 90% 时通知管理员考虑扩容);
|
||||
- 选课截止前提醒(如截止前 24h 通知未选课学生)。
|
||||
- **后果**:缺少这些企业级功能会降低 K12 学校的运营效率。
|
||||
- **修复说明(容量阈值通知)**:
|
||||
- 新增 [data-access-settings.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-settings.ts) 中的 `getCapacityNotifyThreshold()`,默认 0.9(90%),可由 `systemSettings` 表配置(key=`capacityNotifyThreshold`,category=`elective`)。
|
||||
- `data-access-operations.ts` 新增 `notifyCapacityThresholdIfNeeded()`:仅当 `newEnrolledCount === Math.ceil(capacity * threshold)` 时触发一次通知(避免每次递增都发通知)。Fire-and-forget 设计:catch 中吞错并 `console.error`,不阻塞主流程。通过 `notifications.sendNotification` 发送给 `course.teacherId`,type=`"warning"`,附带 `actionUrl` 指向课程详情。i18n 标题/内容通过 `next-intl getTranslations("elective")` 翻译。
|
||||
- `selectCourse` 事务成功后调用 `notifyCapacityThresholdIfNeeded`。
|
||||
- **未实施项**:课程先修要求与选课截止前提醒属于更长期规划,本次审计范围内不实施,后续可在 P3 阶段补齐。
|
||||
|
||||
### 2.19 P2:无单元测试(✅ 2026-06-25 已修复)
|
||||
|
||||
- **位置**:`tests/elective/`(不存在)
|
||||
- **现象**:`buildLotteryRankCase`、`parseSchedule`、`isScheduleConflict`、`buildScopeFilter` 等纯函数已被精心设计为可测试,但没有任何测试文件。
|
||||
- **修复说明**:新增 `tests/integration/elective/elective-pure-functions.test.ts`,35 个单测覆盖 `normalizeDay` / `parseSchedule` / `isScheduleConflict` / `buildLotteryRankCase` / `mapCourseRow`;`vitest.config.ts` 添加 `server-only` 别名 stub。
|
||||
- **违反规则**:「可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock」——架构已就绪,但测试缺失。
|
||||
- **后果**:未来重构无回归保障。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
| 维度 | 行业优秀实践(如 PowerSchool、Veracross、睿睿云、校园钉钉选修) | 当前实现 | 差距影响 |
|
||||
|------|---|---|---|
|
||||
| 角色覆盖 | 4 角色全覆盖:admin 全局管理 / teacher 创建维护 / student 选退课 / parent 查看子女 | 3 角色覆盖,缺 parent | 家长无法监督子女选课,错失家校协同点 |
|
||||
| 时间冲突检测 | 结构化时段编辑器(周几 + 节次),可视化冲突预览 | 纯文本 schedule + 正则解析 | 教师/学生易输入错误格式,冲突检测可能失效 |
|
||||
| 抽签可重跑 | 支持"预抽 + 公示 + 正式抽签",结果可回滚 | 抽完立即 close,不可重抽 | 学校难以应对抽签争议 |
|
||||
| 学分上限 | 按年级/学校可配置 | 全局硬编码 `MAX_CREDIT_PER_TERM=10` | 不同年级无法差异化 |
|
||||
| 选课截止提醒 | 截止前 24h 短信/站内信通知未选课学生 | 无 | 学生错过选课窗口 |
|
||||
| 容量阈值通知 | 满 90% 通知 admin 考虑扩容 | 无 | 热门课程爆满后才发现 |
|
||||
| 课程详情页 | 独立课程详情页 + 教师介绍 + 评价聚合 + 历年选课人数趋势 | 仅卡片展示,无详情页 | 学生选课决策信息不足 |
|
||||
| 选课概览驾驶舱 | admin 数据驾驶舱:选课率、热门科目、班级分布、未选名单 | 仅平铺课程卡片 | admin 缺乏全局视图,决策低效 |
|
||||
| 候补转正通知 | 候补转正时通知学生 | 仅事务内自动转正,无通知 | 学生不知道自己已转正,可能错过上课 |
|
||||
| 课程先修 | 标记先修关系,选课时校验 | 无 | 学生可能跳级选课失败 |
|
||||
| 退课理由 | 退课时要求填写理由 + 期限 | 直接退课,无理由 | 学校无法分析退课原因改进课程 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(必须立即修复,影响功能可用或安全)
|
||||
|
||||
1. **教师页面跳转修复**:把 `createHref`/`editBaseHref` 改为参数化,teacher 页面传入 `/teacher/elective/...`,并新增 teacher 路由 `/teacher/elective/create` 与 `/teacher/elective/[id]/edit`(或共享 admin 路由但放开教师访问)。
|
||||
2. **新增 parent 选课页面**:复用 `StudentSelectionView` 的只读变体,展示子女的已选/可选课程;通过 `parentId + studentId` 双重校验防止信息泄露。
|
||||
3. **admin/student 页面加 `requirePermission()`**:补齐 `ELECTIVE_READ` 校验,与 teacher 一致。
|
||||
4. **data-access 错误消息 i18n 化**:把 `throw new Error("...")` 改为带 i18n key + 参数的结构化错误,由 actions 层用 `getTranslations("elective")` 翻译后再返回。
|
||||
5. **`export.ts` 移除 `as` 断言**:用类型守卫替代。
|
||||
|
||||
### P1(应在本次实施,影响质量与体验)
|
||||
|
||||
6. **`elective-course-form.tsx` 全量 i18n 化**:替换所有硬编码英文为 i18n 键,新增 `form.*` 翻译键。
|
||||
7. **`<a>` → `<Link>`**:`elective-course-list.tsx` 中 2 处替换为 Next.js `<Link>`。
|
||||
8. **错误边界文案修正**:admin/teacher error.tsx 重试按钮改用 `t("actions.retry")`;title/description 分离为 `errors.title` / `errors.description`。
|
||||
9. **`export.ts` 表头全量 i18n**:新增 `export.statusHeader` / `export.priorityHeader` / `export.selectedAtHeader` / `export.enrolledAtHeader` 翻译键。
|
||||
10. **`getElectiveCourses` 不再静默吞错**:移除 try-catch,让异常冒泡到 RSC 触发 error.tsx。
|
||||
11. **`parseSchedule` 支持完整星期 + 多时段**:重写正则与归一化函数。
|
||||
12. **抽签可重跑**:抽签后保留 `status="open"`,仅更新 `enrolledCount`;增加"已抽签"标记字段或独立的 `lotteryRunAt` 字段。
|
||||
13. **管理员选课概览**:在 admin 页面顶部增加统计卡片网格(总课程数、总选课人数、平均容量使用率、待抽签课程数),复用 `attendance-stats-cards.tsx` 模式。
|
||||
|
||||
### P2(中长期改进,提升企业级能力)
|
||||
|
||||
14. **配置化学分上限**(✅ 2026-06-25 已修复):抽离为 `data-access-settings.ts` 中的 `getElectiveCreditLimit(gradeId?)`,复用 `systemSettings` 表按年级可设,未配置 fallback 到全局默认值 10。
|
||||
15. **Suspense 流式渲染**(✅ 2026-06-25 已修复):student 页面拆分"我的选课"与"可选课程"为两个独立 Suspense 边界,admin 列表与统计卡片分离。
|
||||
16. **容量阈值通知**(✅ 2026-06-25 已修复):选课时若 `enrolledCount >= capacity * threshold`,触发 `notifications` 模块通知教师(type=`"warning"`),阈值通过 `getCapacityNotifyThreshold()` 可配置,默认 0.9。
|
||||
17. **退课期限与理由**(✅ 2026-06-25 已修复):新增 `electiveCourses.dropDeadline`(datetime)与 `courseSelections.dropReason`(varchar 255)字段,`dropCourse` 校验截止时间并抛 `ElectiveBusinessError("dropDeadlinePassed")`,退课对话框可选填理由,trim 后非空才入库。
|
||||
18. **单元测试**(✅ 2026-06-25 已修复):补齐 `parseSchedule` / `isScheduleConflict` / `buildLotteryRankCase` / `buildScopeFilter` / `mapCourseRow` 的单元测试。
|
||||
19. **课程详情页**(✅ 2026-06-25 已修复):新增 `/admin/elective/[id]` 与 `/student/elective/[id]` 详情页。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 需要修正的过时信息
|
||||
|
||||
`004_architecture_impact_map.md` 与 `005_architecture_data.json` 中 elective 模块章节的「已知问题」存在以下过时项,本次审计已核实并修复:
|
||||
|
||||
| 架构图记录 | 实际情况 | 处理 |
|
||||
|---|---|---|
|
||||
| "❌ P0:3 个读 Action 无调用方" | 实际并不存在这 3 个 Action;页面直接调用 data-access(项目规则允许 `app/` 调用 data-access) | 删除该项 |
|
||||
| "❌ P0:i18n 完全缺失" | i18n 资源完整(zh-CN + en 双语),Server Action 错误消息已 i18n 化 | 修正为「P0:data-access-operations 的 throw 错误消息仍为英文」 |
|
||||
| "❌ P0:错误边界完全缺失(3 个角色目录均无 `error.tsx`)" | 3 个 `error.tsx` 已存在 | 删除该项;改为「P1:error.tsx 文案错误(title=description=unexpected,按钮文案错误)」 |
|
||||
| "⚠️ P1:`elective-course-form.tsx` 存在 `v as "fcfs" | "lottery"` 类型断言" | 已用 `isSelectionMode` 类型守卫替代,无 `as` | 删除该项 |
|
||||
| "⚠️ P1:`elective-course-list.tsx` 存在 `null as never` 类型逃逸" | 当前代码无 `null as never` | 删除该项 |
|
||||
| "⚠️ P1:`buildLotteryRankCase` 未导出,无法单测" | 已 `export function buildLotteryRankCase` | 删除该项 |
|
||||
| "⚠️ P1:`SELECTION_MODE_LABELS` 已定义但表单未复用" | 表单已使用 `isSelectionMode` 守卫 + 直接渲染 `t("selectionMode.fcfs/lottery")` | 删除该项 |
|
||||
|
||||
### 5.2 需要新增的节点
|
||||
|
||||
| 新增项 | 004 章节 | 005 节点 |
|
||||
|---|---|---|
|
||||
| 新增 `parent/elective/page.tsx`(家长查看子女选课) | 2.20 文件清单新增一行 | `appRoutes.parent.elective` 节点 |
|
||||
| 新增 `teacher/elective/create/page.tsx` 与 `teacher/elective/[id]/edit/page.tsx` | 2.20 文件清单新增 2 行 | `appRoutes.teacher.electiveCreate` / `electiveEdit` 节点 |
|
||||
| 新增 `data-access-stats.ts`(管理员选课统计) | 2.20 文件清单新增一行 | `modules.elective.exports.dataAccess` 新增函数 |
|
||||
| 新增 `tests/elective/*.test.ts`(单元测试) | 2.20 文件清单新增测试说明 | 无需 005 节点(测试不属导出) |
|
||||
|
||||
### 5.3 实施完成后的同步动作
|
||||
|
||||
代码修改完成后,将上述变更同步写入:
|
||||
- `docs/architecture/004_architecture_impact_map.md` 第 2.20 节
|
||||
- `docs/architecture/005_architecture_data.json` 的 `modules.elective` 与 `appRoutes` 节点
|
||||
274
docs/architecture/audit/error-book-audit-report.md
Normal file
274
docs/architecture/audit/error-book-audit-report.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# 错题本模块审计报告
|
||||
|
||||
> 审计日期:2026-06-24
|
||||
> 审计范围:`src/modules/error-book/` 及 `src/app/(dashboard)/{student,teacher,parent,admin}/error-book/`
|
||||
> 审计依据:项目规则 `docs/standards/coding-standards.md`、架构影响地图 `004`/`005`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
错题本模块位于 `src/modules/error-book/`,包含以下文件:
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | 341 | 9 个 Server Actions(列表/详情/统计/增删改/复习/采集) |
|
||||
| `data-access.ts` | **1029** | 数据访问层(学生 CRUD + 教师/管理员分析查询 + 跨模块接口) |
|
||||
| `data-access-collection.ts` | 170 | 自动采集逻辑(考试/作业错题采集) |
|
||||
| `schema.ts` | 51 | 4 个 Zod 验证 schema |
|
||||
| `types.ts` | 245 | 11 个类型定义 + 状态映射常量 |
|
||||
| `sm2-algorithm.ts` | 177 | SM-2 间隔重复算法(纯函数) |
|
||||
| `sm2-algorithm.test.ts` | - | 39 个单元测试 |
|
||||
| `components/` | 17 个文件 | UI 组件(学生卡片/列表/筛选/详情/图表等) |
|
||||
|
||||
### 1.2 路由分布
|
||||
|
||||
| 路由 | 角色 | 文件完整性 |
|
||||
|------|------|-----------|
|
||||
| `/student/error-book` | 学生 | page + loading + error ✅ |
|
||||
| `/teacher/error-book` | 教师 | page + loading + error ✅ |
|
||||
| `/parent/error-book` | 家长 | page + loading + error ✅ |
|
||||
| `/admin/error-book` | 管理员 | page + loading + error ✅ |
|
||||
|
||||
### 1.3 架构图覆盖情况
|
||||
|
||||
架构影响地图 `004_architecture_impact_map.md` 第 2.28 节已覆盖该模块,记录了:
|
||||
- 模块职责、导出函数、权限点、DataScope 行级权限
|
||||
- SM-2 算法说明、自动采集机制
|
||||
- 文件清单、路由清单、数据库表
|
||||
|
||||
**但存在以下不一致**:
|
||||
1. `005_architecture_data.json` 的 `uses.shared` 仍列出 `examSubmissions`、`submissionAnswers`、`homeworkSubmissions` 等表,实际上这些已通过跨模块 data-access 接口访问(`data-access-collection.ts`),不再直接查询
|
||||
2. JSON 未记录 `adaptive-practice` 依赖,但 `error-book-detail-dialog.tsx` 直接 import 了 `createPracticeSessionAction`
|
||||
3. JSON 未记录 `ai` 模块依赖,但 `error-book-detail-dialog.tsx` 直接 import 了 `AiErrorBookAnalysis` 组件
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 【P0】跨模块直接依赖(违反三层架构规则)
|
||||
|
||||
**项目规则**:「模块间只能通过对方 data-access 通信,禁止跨模块直接查询数据库表」「该模块必须作为独立功能单元」
|
||||
|
||||
| 位置 | 违规内容 | 原因 | 后果 |
|
||||
|------|---------|------|------|
|
||||
| [error-book-detail-dialog.tsx](file:///e:/Desktop/CICD/src/modules/error-book/components/error-book-detail-dialog.tsx#L40) | `import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"` | error-book 组件直接 import ai 模块组件 | 模块强耦合,无法独立测试/部署 |
|
||||
| [error-book-detail-dialog.tsx](file:///e:/Desktop/CICD/src/modules/error-book/components/error-book-detail-dialog.tsx#L41) | `import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"` | error-book 组件直接 import adaptive-practice 的 Server Action | 模块强耦合,违反依赖注入原则 |
|
||||
| [add-error-book-dialog.tsx](file:///e:/Desktop/CICD/src/modules/error-book/components/add-error-book-dialog.tsx#L26) | `import { getQuestionsAction } from "@/modules/questions/actions"` | error-book 组件直接 import questions 模块的 Action | 应通过 data-access 或注入接口调用 |
|
||||
|
||||
### 2.2 【P0】i18n 国际化严重遗漏
|
||||
|
||||
**项目规则**:「所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键」
|
||||
|
||||
虽然 `error-book.json` 翻译文件已存在(123 行),但**大量组件仍使用硬编码中文**:
|
||||
|
||||
| 组件 | 硬编码文本示例 | 行数 |
|
||||
|------|--------------|------|
|
||||
| `error-book-item-card.tsx` | "题目内容"、"难度"、"掌握度"、"复习 X 次"、"需复习"、"下次"、"未学习"、"入门"... | 10+ 处 |
|
||||
| `error-book-detail-dialog.tsx` | "题目"、"我的答案"、"正确答案"、"AI 智能分析"、"复习自评"、"学习笔记"、"错误原因标签"、"复习历史"、"归档"、"删除"、"添加于"... | 20+ 处 |
|
||||
| `error-book-filters.tsx` | "搜索笔记内容..."、"状态"、"来源"、"复习"、"全部状态"、"全部来源"... | 8+ 处 |
|
||||
| `add-error-book-dialog.tsx` | "手动添加"、"添加错题"、"选择题目"、"学习笔记(可选)"、"错误原因标签"、"取消"、"添加"... | 10+ 处 |
|
||||
| `review-buttons.tsx` | "重来"、"困难"、"良好"、"简单" 及描述文案(i18n 已有翻译但未使用) | 8 处 |
|
||||
| `error-book-stats-cards.tsx` | "错题总数"、"待学习"、"学习中"、"已掌握"、"待复习" 及描述 | 10 处 |
|
||||
| `analytics-stats-cards.tsx` | "覆盖学生"、"错题总数"、"平均掌握率"、"待复习"、"涉及知识点" 及子文案 | 10+ 处 |
|
||||
| `subject-tabs.tsx` | "全部学科"、"待复习" | 2 处 |
|
||||
| `class-filter.tsx` | "全部班级"、"错题"、"待复习" | 3 处 |
|
||||
| `top-wrong-questions.tsx` | "高频错题"、"高频错题 Top 10"、"暂无高频错题"、"人错"、"人已掌握"、"掌握率" | 8 处 |
|
||||
| `knowledge-point-weakness-chart.tsx` | "薄弱知识点 Top X"、"错题数"、"所属章节"、"错题数"、"已掌握"、"掌握率" | 8+ 处 |
|
||||
| `chapter-weakness-chart.tsx` | "章节错题分布(哪些课在错)"、"错题数"、"已掌握"、"掌握率"、"知识点数"、"薄弱知识点" | 10+ 处 |
|
||||
| `class-error-bar-chart.tsx` | "各班级错题数对比"、"错题总数"、"学生数"、"人均错题"、"平均掌握率"、"待复习" | 6+ 处 |
|
||||
| `subject-distribution-chart.tsx` | "各学科错题分布"、"错题数"、"已掌握"、"掌握率" | 4+ 处 |
|
||||
| `grouped-student-error-table.tsx` | "未分班"、"人"、"人有错题"、"错题总数"、"平均掌握率"、"学生" 及表头 | 15+ 处 |
|
||||
| `class-error-overview.tsx` | "覆盖学生"、"错题总数"、"平均掌握率"、"薄弱知识点"、"学科错题分布" 等 | 15+ 处 |
|
||||
| `error-book-list.tsx` | "错题本为空"、"查看详情" | 2 处 |
|
||||
| `teacher/error-book/page.tsx` | "错题分析"、"按学科、班级查看学生的错题统计与薄弱知识点" 等 | 8+ 处 |
|
||||
|
||||
**总计约 150+ 处硬编码中文文本**,违反 i18n 规则。
|
||||
|
||||
### 2.3 【P0】类型安全问题(违反 TypeScript 严格模式)
|
||||
|
||||
**项目规则**:「禁止 `any`、禁止 `as` 断言(除类型收窄外)」
|
||||
|
||||
| 文件 | 行号 | 违规代码 | 类型 |
|
||||
|------|------|---------|------|
|
||||
| `data-access.ts` | 77 | `row.sourceType as ErrorBookItem["sourceType"]` | `as` 断言 |
|
||||
| `data-access.ts` | 82 | `row.knowledgePointIds as string[] \| null` | `as` 断言 |
|
||||
| `data-access.ts` | 90 | `row.errorTags as string[] \| null` | `as` 断言 |
|
||||
| `data-access.ts` | 133 | `or(...)!` | 非空断言 |
|
||||
| `data-access.ts` | 175 | `row as unknown as Parameters<typeof mapRowToItem>[0]` | 双重断言 |
|
||||
| `data-access.ts` | 222 | 同上 | 双重断言 |
|
||||
| `data-access.ts` | 657 | `row.knowledgePointIds as string[] \| null` | `as` 断言 |
|
||||
| `data-access.ts` | 792 | 同上 | `as` 断言 |
|
||||
| `actions.ts` | 63 | `params.status as "new" \| "learning" \| ...` | `as` 断言 |
|
||||
| `actions.ts` | 69 | `params.sourceType as "exam" \| "homework" \| ...` | `as` 断言 |
|
||||
| `error-book-item-card.tsx` | 31 | `node as Record<string, unknown>` | `as` 断言 |
|
||||
| `error-book-detail-dialog.tsx` | 61 | `content as Record<string, unknown>` | `as` 断言 |
|
||||
| `add-error-book-dialog.tsx` | 48 | `node as Record<string, unknown>` | `as` 断言 |
|
||||
| `top-wrong-questions.tsx` | 26 | `node as Record<string, unknown>` | `as` 断言 |
|
||||
| `knowledge-point-weakness-chart.tsx` | 77 | `payload as unknown as {...}` | 双重断言 |
|
||||
| `chapter-weakness-chart.tsx` | 74 | `payload as unknown as {...}` | 双重断言 |
|
||||
| `class-error-bar-chart.tsx` | 76 | `payload as unknown as {...}` | 双重断言 |
|
||||
| `subject-distribution-chart.tsx` | 77 | `payload as unknown as {...}` | 双重断言 |
|
||||
|
||||
### 2.4 【P1】data-access.ts 超过 1000 行硬性上限
|
||||
|
||||
**项目规则**:「硬性上限:任何文件不超过 1000 行,超过必须拆分」
|
||||
|
||||
`data-access.ts` 当前 **1029 行**,超出硬性上限。该文件混合了:
|
||||
- 学生端 CRUD(`getErrorBookItems`、`createErrorBookItem`、`recordReview` 等)
|
||||
- 教师/管理员分析查询(`getStudentErrorBookSummaries`、`getKnowledgePointWeakness`、`getChapterWeakness`、`getClassErrorOverviews`、`getSubjectErrorOverviews` 等)
|
||||
- 跨模块查询接口(`getStudentIdsByClassIdList`、`getAllStudentIds`)
|
||||
|
||||
### 2.5 【P1】性能问题:全量查询 + JS 端聚合
|
||||
|
||||
| 函数 | 问题 | 影响 |
|
||||
|------|------|------|
|
||||
| `getErrorBookStats` | 查询该学生**所有**错题行到内存,再 JS 循环统计 | 学生错题多时内存/CPU 浪费 |
|
||||
| `getStudentErrorBookSummaries` | 查询所有学生的所有错题行,再 JS 聚合 | 班级学生多时性能差 |
|
||||
| `getKnowledgePointWeakness` | 查询所有错题行,JS 展开知识点数组再统计 | 同上 |
|
||||
| `getChapterWeakness` | 同上 | 同上 |
|
||||
| `getSubjectErrorDistribution` | 同上 | 同上 |
|
||||
| `getClassErrorOverviews` | 同上 | 同上 |
|
||||
| `getSubjectErrorOverviews` | 同上 | 同上 |
|
||||
| `getTopWrongQuestionsByStudentIds` | 同上 | 同上 |
|
||||
| `admin/page.tsx` | `allStudentIds.slice(0, 500)` 硬编码限制,无分页 | 超过 500 学生时数据不完整 |
|
||||
|
||||
**应使用 SQL `GROUP BY` + `COUNT` 聚合查询**,避免全量加载到内存。
|
||||
|
||||
### 2.6 【P1】a11y 可访问性缺失
|
||||
|
||||
**项目规则**:「可访问性(a11y):语义化标签、ARIA 属性、键盘导航」
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `grouped-student-error-table.tsx:162` | 使用 `<a href>` 而非 Next.js `<Link>`(违反项目记忆中的 Link 规范) |
|
||||
| `subject-tabs.tsx` | `<button>` 缺少 `role="tab"` / `aria-selected` / `aria-controls` |
|
||||
| `class-filter.tsx` | 同上 |
|
||||
| 所有图表组件 | 缺少 `aria-label` 描述图表内容 |
|
||||
| `review-buttons.tsx` | 按钮缺少 `aria-label` 描述操作 |
|
||||
| `grouped-student-error-table.tsx:96` | 可展开行缺少 `aria-expanded` |
|
||||
| `error-book-detail-dialog.tsx` | `<textarea>` 缺少 `aria-label` |
|
||||
|
||||
### 2.7 【P1】错误边界不完整
|
||||
|
||||
**项目规则**:「每个独立的数据区块必须用 React Error Boundary 包裹」
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `parent/error-book/page.tsx` | 无 `Suspense` 包裹,整个页面同步渲染 |
|
||||
| 所有页面 | 仅页面级 `error.tsx`,各数据区块(统计卡片/图表/表格)无独立 Error Boundary |
|
||||
| 图表组件 | recharts 渲染失败时无 fallback |
|
||||
|
||||
### 2.8 【P2】组件复用问题
|
||||
|
||||
| 问题 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `extractQuestionPreview` 函数重复 3 次 | `error-book-item-card.tsx`、`add-error-book-dialog.tsx`、`top-wrong-questions.tsx` | 应提取到 shared 工具函数 |
|
||||
| `extractQuestionText` 函数重复 | `error-book-detail-dialog.tsx` | 与上面类似但实现不同 |
|
||||
| `MASTERY_LEVEL_LABELS` 硬编码 | `error-book-item-card.tsx:47` | 应使用 i18n |
|
||||
| `QUESTION_TYPE_LABEL` 硬编码 | `top-wrong-questions.tsx:35` | 应使用 i18n |
|
||||
| `class-error-overview.tsx` 导出 `StudentErrorTable` | 似乎已被 `GroupedStudentErrorTable` 取代 | 死代码 |
|
||||
| `COMMON_ERROR_TAGS` 硬编码中文 | `types.ts:55` | 应使用 i18n 键 |
|
||||
|
||||
### 2.9 【P2】数据服务未抽象(不可测试)
|
||||
|
||||
**项目规则**:「可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离」
|
||||
|
||||
当前组件直接 import `actions` 和 `data-access`:
|
||||
- `error-book-detail-dialog.tsx` 直接 import `archiveErrorBookItemAction`、`deleteErrorBookItemAction`、`updateErrorBookNoteAction`
|
||||
- `add-error-book-dialog.tsx` 直接 import `createErrorBookItemAction`
|
||||
- `review-buttons.tsx` 直接 import `reviewErrorBookItemAction`
|
||||
|
||||
**无法在不启动整个应用的情况下 mock 这些依赖**,违反可测试性原则。
|
||||
|
||||
### 2.10 【P2】权限校验位置不统一
|
||||
|
||||
`getAllStudentIds()` 在 data-access 层通过 `roles.name === "student"` 查询,虽然不在前端,但角色名字符串硬编码在查询中。应使用 `shared/types/permissions` 中的角色常量。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与优秀 K12 产品的差距
|
||||
|
||||
| 功能 | 我们 | 智学网 | 猿题库 | 钉钉教育 | 差距影响 |
|
||||
|------|------|--------|--------|---------|---------|
|
||||
| 智能复习队列 | ✅ SM-2 | ✅ | ✅ | ❌ | 基本持平 |
|
||||
| 复习提醒通知 | ❌ | ✅ 推送 | ✅ 推送 | ✅ | 学生不知道何时复习 |
|
||||
| 错题趋势图 | ❌(类型已定义未实现) | ✅ | ✅ | ❌ | 无法看到进步趋势 |
|
||||
| 导出/打印错题 | ❌ | ✅ PDF | ✅ PDF | ❌ | 无法离线复习 |
|
||||
| 批量操作 | ❌ | ✅ | ✅ | ❌ | 管理大量错题效率低 |
|
||||
| 班级平均对比 | ❌ | ✅ | ✅ | ❌ | 学生不知道自己水平 |
|
||||
| 复习日历视图 | ❌ | ✅ | ✅ | ❌ | 无法直观看到复习安排 |
|
||||
| 错题来源详情跳转 | ❌ | ✅ | ✅ | ❌ | 无法回看原试卷/作业 |
|
||||
| 知识点掌握度雷达图 | ❌ | ✅ | ✅ | ❌ | 无法多维度看薄弱点 |
|
||||
| 游戏化激励(连续复习天数) | ❌ | ✅ | ✅ | ❌ | 学生缺乏复习动力 |
|
||||
| 智能推题(基于错题变式) | ✅(已接入 adaptive-practice) | ✅ | ✅ | ❌ | 基本持平 |
|
||||
|
||||
### 3.2 UI/UX 差距
|
||||
|
||||
| 差距 | 说明 | 影响角色 |
|
||||
|------|------|---------|
|
||||
| 学生端缺少复习仪表盘 | 当前只有列表+筛选,无"今日待复习"独立视图 | 学生 |
|
||||
| 教师端缺少学生个体下钻 | 点击学生只能跳转带参数,无学生错题详情面板 | 教师 |
|
||||
| 家长端无子女切换 | 多子女时用卡片展示,无 Tab 切换对比 | 家长 |
|
||||
| 管理员端无年级维度 | 只有全校维度,无年级/班级下钻 | 管理员 |
|
||||
| 无骨架屏一致性 | 各页面 loading.tsx 结构不一致 | 全部 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响架构合规与安全)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | 跨模块直接依赖 | 将 `AiErrorBookAnalysis`、`createPracticeSessionAction`、`getQuestionsAction` 改为通过 props/Context 注入,error-book 模块不直接 import 其他业务模块 |
|
||||
| P0-2 | i18n 硬编码(150+ 处) | 全部提取为翻译键,扩展 `error-book.json` 翻译文件 |
|
||||
| P0-3 | 类型安全(18 处 as 断言) | 使用类型守卫替代 `as`,图表 tooltip 使用泛型组件 |
|
||||
|
||||
### P1(重要,影响性能与可访问性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | data-access.ts 超 1000 行 | 拆分为 `data-access.ts`(学生 CRUD)+ `data-access-analytics.ts`(教师/管理员分析) |
|
||||
| P1-2 | 全量查询 + JS 聚合 | 改用 SQL `GROUP BY` + `COUNT` 聚合 |
|
||||
| P1-3 | a11y 缺失 | 添加 ARIA 属性、语义化标签、`<Link>` 替代 `<a>` |
|
||||
| P1-4 | 错误边界不完整 | 为各数据区块添加 Error Boundary |
|
||||
| P1-5 | admin 无分页 | 实现分页查询或虚拟滚动 |
|
||||
|
||||
### P2(优化,提升可维护性与用户体验)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 重复函数 | 提取 `extractQuestionPreview` 到 `shared/lib/question-content.ts`(已存在) |
|
||||
| P2-2 | 数据服务未抽象 | 定义 `ErrorBookService` 接口,通过 Context 注入 |
|
||||
| P2-3 | 死代码 | 删除 `class-error-overview.tsx` 中未使用的 `StudentErrorTable` |
|
||||
| P2-4 | 缺失功能 | 错题趋势图、复习提醒、导出、批量操作(中长期) |
|
||||
| P2-5 | 角色字符串硬编码 | `getAllStudentIds` 使用角色常量 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 需要修改的节点
|
||||
|
||||
1. **`005_architecture_data.json` → `error-book.uses.shared`**:
|
||||
- 移除 `db.schema.examSubmissions`、`db.schema.submissionAnswers`、`db.schema.homeworkSubmissions`、`db.schema.homeworkAnswers`、`db.schema.examQuestions`、`db.schema.homeworkAssignmentQuestions`(这些已通过跨模块 data-access 访问)
|
||||
|
||||
2. **`005_architecture_data.json` → `error-book.dependsOn`**:
|
||||
- 新增 `ai`(error-book-detail-dialog 直接依赖 ai 组件)
|
||||
- 新增 `adaptive-practice`(error-book-detail-dialog 直接依赖其 Action)
|
||||
- 新增 `questions`(add-error-book-dialog 直接依赖其 Action)—— 注意:questions 已在 dependsOn 中,但 uses 中记录的是 `actions.getQuestionsAction` 而非 data-access
|
||||
|
||||
3. **`004_architecture_impact_map.md` 第 2.28 节**:
|
||||
- 文件清单中 `data-access.ts` 行数更新为拆分后的两个文件
|
||||
- 依赖关系新增 ai / adaptive-practice 的直接依赖说明(标注为"待解耦")
|
||||
|
||||
### 5.2 架构图待补充项
|
||||
|
||||
- 拆分后的 `data-access-analytics.ts` 文件信息
|
||||
- `ErrorBookService` 接口定义(如实施 P2-2)
|
||||
- i18n 翻译文件结构更新
|
||||
475
docs/architecture/audit/exams-audit-report.md
Normal file
475
docs/architecture/audit/exams-audit-report.md
Normal file
@@ -0,0 +1,475 @@
|
||||
# 考试(exams)模块审计报告
|
||||
|
||||
> 审计时间:2026-06-25
|
||||
> 审计范围:`src/modules/exams/**`(35+ 文件)+ `src/app/(dashboard)/teacher/exams/**`(10 个页面)+ 跨模块依赖面
|
||||
> 审计依据:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、项目 `project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布与体量
|
||||
|
||||
exams 模块按职责已做较细粒度拆分,体量基本符合规范:
|
||||
|
||||
| 子目录/文件 | 行数(参考架构图) | 职责 |
|
||||
|-------------|------|------|
|
||||
| `actions.ts` | 633 | 11 个核心 Server Action(已从 1525 行拆分) |
|
||||
| `actions-helpers.ts` | 96 | 跨 Action 共享纯函数(prepareExamCreateContext 等) |
|
||||
| `actions-rich-editor.ts` | 250 | 富文本编辑器 Server Action(create/update) |
|
||||
| `ai-pipeline/auto-mark.ts` | 356 | AI 自动标记 Server Action + 纯转换函数 |
|
||||
| `ai-pipeline/{index,parse,request,structure}.ts` | — | AI 调用/解析/结构化 |
|
||||
| `data-access.ts` | 542 | 考试 CRUD(已从 1036 行拆分) |
|
||||
| `data-access-cross-module.ts` | 511 | 13 个跨模块查询/写接口 |
|
||||
| `data-access-error-collection.ts` | — | 错题采集相关跨模块接口 |
|
||||
| `stats-service.ts` | 158 | 考试分析数据聚合 |
|
||||
| `types.ts` | 93 | 类型定义 |
|
||||
| `utils/normalize-structure.ts` | 57 | exam.structure 运行时归一化 |
|
||||
| `components/` | 24 个文件 | 表单/组卷/预览/分析/卡片/筛选/表格 |
|
||||
| `editor/` | 14 个文件 | Tiptap 富文本编辑器(extensions/utils/转换) |
|
||||
| `hooks/` | 4 个文件 | use-exam-preview 主组合器 + 3 个子 Hook |
|
||||
|
||||
**架构图覆盖情况**:004/005 已记录 exams 模块的职责、依赖、被依赖、文件清单、P0/P1 修复历史、V3 增强项。本次审计对照架构图核对,覆盖基本完整,但以下细节需补全(见第五节):
|
||||
- `data-access-error-collection.ts` 未在 005 JSON 的 modules.exams.exports 中列出
|
||||
- `utils/normalize-structure.ts` 已记录但未在 005 的 dependencyMatrix 中明确标注被 `[id]/build/page.tsx` 与 `[id]/edit-rich/page.tsx` 引用
|
||||
|
||||
### 1.2 主要数据流
|
||||
|
||||
- **创建**:`/teacher/exams/create` → `createExamAction` → `persistExamDraft` → `db.insert(exams)`
|
||||
- **AI 创建**:`/teacher/exams/create` → `createAiExamAction` → `loadAiDraftQuestionsAndStructure` → `persistAiGeneratedExamDraft` → 通过 `questions/data-access.createQuestionWithRelations` 创建题目 → 事务写 exams + examQuestions
|
||||
- **富文本创建**:`/teacher/exams/new` → `createExamFromRichEditorAction` → `editorDocToStructure` → `persistAiGeneratedExamDraft`
|
||||
- **组卷**:`/teacher/exams/[id]/build` → `ExamAssembly` + `getExamById`
|
||||
- **预览**:`previewAiExamAction` / `getExamPreviewAction`
|
||||
- **分析**:`/teacher/exams/[id]/analytics` → `getExamAnalytics`(聚合 homework 提交数据)
|
||||
|
||||
### 1.3 跨模块依赖(合规项)
|
||||
|
||||
以下跨模块调用均通过对方 data-access,符合三层架构规则:
|
||||
- `questions/data-access.createQuestionWithRelations`(P0-1 已修复)
|
||||
- `classes/data-access.getClassGradeIdsByClassIds`(P0-2 已修复)
|
||||
- `school/data-access.{getSubjectNameById,getGradeNameById,getSubjectOptions,getGradeOptions}`(P1-1 已修复)
|
||||
- `homework/data-access.{getHomeworkAssignmentsByExamId,getGradedSubmissionsByExamId}`(V3-8 新增)
|
||||
- `homework/data-access-utils.getQuestionText`
|
||||
|
||||
### 1.4 已修复的历史问题(架构图记录)
|
||||
|
||||
P0-1/P0-2/P0-4/P0-8/P1-1 等历史违规已修复,详见 004 文档第 2.2 节。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 🔴 2.1【架构违规·P0】跨模块直接 JOIN questions 表
|
||||
|
||||
**位置**:[data-access-cross-module.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-cross-module.ts#L4-L5) 第 4 行 import、第 480-490 行 `getExamForGradeEntry`
|
||||
|
||||
**问题**:
|
||||
```
|
||||
第 4 行:import { exams, examQuestions, examSubmissions, submissionAnswers, questions } from "@/shared/db/schema"
|
||||
第 488 行:.innerJoin(questions, eq(examQuestions.questionId, questions.id))
|
||||
```
|
||||
|
||||
`getExamForGradeEntry` 为了获取题目 `type` 字段,直接 JOIN 了 questions 模块的核心表 `questions`。
|
||||
|
||||
**违反规则**:项目规则"模块间只能通过对方 data-access 通信,**禁止跨模块直接查询数据库表**"。
|
||||
|
||||
**原因**:成绩录入表格表头需要题目类型,但实现时未在 questions 模块暴露按 ID 批量获取类型的接口,于是直接 JOIN。
|
||||
|
||||
**直接后果**:questions 模块若重构表结构(如将 type 拆分到独立表),exams 模块会编译失败或运行时错误;模块封装性被破坏,违反可测试性与可替换性。
|
||||
|
||||
---
|
||||
|
||||
### 🟢 2.2【已确认合规】submissionAnswers 表归属与直查
|
||||
|
||||
**位置**:
|
||||
- [data-access-cross-module.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-cross-module.ts#L4) 导入 `submissionAnswers`
|
||||
- [data-access-cross-module.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-cross-module.ts#L212-L218) `getExamSubmissionWithAnswers` 直查 `submissionAnswers`
|
||||
- [data-access-error-collection.ts](file:///e:/Desktop/CICD/src/modules/exams/data-access-error-collection.ts#L6) 导入并查询 `submissionAnswers`(第 62-69 行)
|
||||
|
||||
**结论**:经核对 `src/shared/db/schema.ts:575-578`,`submissionAnswers` 表的 `submissionId` 外键引用 `examSubmissions.id`,**该表属于 exams 模块自身域**(exam submissions 的答题记录)。exams 模块查询自己的表合规,`getExamSubmissionWithAnswers` 与 `getExamSubmissionDataForErrorCollection` 通过 data-access-cross-module 暴露给 diagnostic/error-book 模块调用,符合"模块间通过对方 data-access 通信"规则。
|
||||
|
||||
**无违规,无需修复。**
|
||||
|
||||
---
|
||||
|
||||
### 🟠 2.3【i18n 缺失·P1】11 个组件未接入 useTranslations
|
||||
|
||||
**位置**:
|
||||
|
||||
| 文件 | 硬编码样本 |
|
||||
|------|-----------|
|
||||
| [components/exam-card.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-card.tsx#L78-L91) | "Lvl"、"min"、"pts"、"Questions" |
|
||||
| [components/exam-filters.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-filters.tsx#L33-L59) | "Search exams..."、"Status"、"Any Status"、"Draft"、"Published"、"Archived"、"Difficulty"、"Easy (1)" 等 |
|
||||
| [components/exam-preview-dialog.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-preview-dialog.tsx#L89-L199) | "Section"、"未命名题目"、"未命名子题"、"Exam Preview"、"Generating preview..."、"完整试卷预览"、"题 · 科目 · 年级 · 分钟 · 总分"、"No preview available"、"Confirm & Create" |
|
||||
| [components/exam-viewer.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-viewer.tsx#L95-L197) | "Section"、"Group"、"Score:"、"No questions available." |
|
||||
| [components/question-options-editor.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/question-options-editor.tsx) | 选项编辑器中文硬编码 |
|
||||
| [editor/extensions/blank-node.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/blank-node.tsx) | aria-label="填空" |
|
||||
| [editor/extensions/group-block.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/group-block.tsx) | placeholder 与统计文案硬编码 |
|
||||
| [editor/extensions/question-block.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/question-block.tsx) | 题型 `<option>` 与 "分" 硬编码 |
|
||||
| [editor/extensions/section-block.tsx](file:///e:/Desktop/CICD/src/modules/exams/editor/extensions/section-block.tsx) | "层级/卷/部分/分卷" 硬编码 |
|
||||
|
||||
**违反规则**:项目规则"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键";硬约束"All user-visible text must be i18n-adapted using next-intl with translation keys extracted"。
|
||||
|
||||
**原因**:富文本编辑器 extensions 与早期组件(exam-card/exam-filters/exam-preview-dialog)在 i18n 改造前已存在,后续 i18n 改造未覆盖到。
|
||||
|
||||
**直接后果**:
|
||||
- 多语言环境(en)下用户看到中英混杂文本,体验严重劣化
|
||||
- 无法通过翻译文件统一管理文案,难以维护
|
||||
- exam-card 在 all 列表页是高频可见组件,影响首屏专业度
|
||||
|
||||
---
|
||||
|
||||
### 🟠 2.4【i18n 缺失·P1】Server Action 返回消息绕过 i18n
|
||||
|
||||
**位置**:
|
||||
|
||||
| 文件:行号 | 硬编码消息 |
|
||||
|-----------|-----------|
|
||||
| [actions-rich-editor.ts:41](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L41) | "标题不能为空" |
|
||||
| [actions-rich-editor.ts:49,55](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L49) | "试卷内容不能为空" |
|
||||
| [actions-rich-editor.ts:123,202](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L123) | "试卷内容格式无效"(safeJsonParse 兜底参数) |
|
||||
| [actions-rich-editor.ts:125,204](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L125) | "试卷内容解析失败" |
|
||||
| [actions-rich-editor.ts:169](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L169) | "试卷草稿已创建" |
|
||||
| [actions-rich-editor.ts:211](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L211) | "只能更新自己创建的试卷" |
|
||||
| [actions-rich-editor.ts:278](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L278) | "试卷已更新" |
|
||||
| [actions.ts:161,250,465](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L161) | "题目数据格式无效"(safeJsonParse 兜底) |
|
||||
| [actions.ts:466](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L466) | "试卷结构数据格式无效" |
|
||||
| [ai-pipeline/auto-mark.ts:30](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/auto-mark.ts#L30) | "试卷文本不能为空"(schema message) |
|
||||
| [ai-pipeline/auto-mark.ts:386](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/auto-mark.ts#L386) | "AI 自动标记完成" |
|
||||
| [stats-service.ts:136](file:///e:/Desktop/CICD/src/modules/exams/stats-service.ts#L136) | "(无题目文本)" |
|
||||
| [actions-helpers.ts:65](file:///e:/Desktop/CICD/src/modules/exams/actions-helpers.ts#L65) | "Invalid form data" |
|
||||
|
||||
**违反规则**:同 2.3。`actions.ts` 主体已使用 `getTranslations("examHomework.exam.actionMessages")`,但 `actions-rich-editor.ts` 与 `ai-pipeline/auto-mark.ts` 完全未接入,存在 i18n 一致性破口。
|
||||
|
||||
**原因**:这两个文件是从 actions.ts 拆分出来的新文件,拆分时未同步迁移 i18n 模式。
|
||||
|
||||
**直接后果**:富文本编辑器与 AI 自动标记的错误/成功提示在非中文环境下显示中文,破坏产品一致性。
|
||||
|
||||
---
|
||||
|
||||
### 🟠 2.5【路由边界缺失·P1】部分路由缺 loading.tsx / error.tsx
|
||||
|
||||
**位置**:[src/app/(dashboard)/teacher/exams/](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/)
|
||||
|
||||
| 路由 | loading.tsx | error.tsx |
|
||||
|------|------------|----------|
|
||||
| `all/` | ✅ | ❌ |
|
||||
| `create/` | ✅ | ❌ |
|
||||
| `new/` | ❌ | ❌ |
|
||||
| `[id]/build/` | ✅ | ✅ |
|
||||
| `[id]/edit-rich/` | ❌ | ❌ |
|
||||
| `[id]/analytics/` | ❌ | ❌ |
|
||||
| `[id]/proctoring/` | ✅ | ✅ |
|
||||
|
||||
**违反规则**:硬约束"All student routes must include loading.tsx and error.tsx for error boundaries"(项目内存中虽针对 student 路由,但企业级规范同样适用于 teacher 路由);规则"每个独立的数据区块必须用 React Error Boundary 包裹"、"异步数据使用 React Suspense + 骨架屏"。
|
||||
|
||||
**原因**:路由按需添加 loading/error,未系统化覆盖。
|
||||
|
||||
**直接后果**:
|
||||
- 编辑器页面(edit-rich)加载 Tiptap 较慢,无骨架屏会白屏
|
||||
- 分析页(analytics)聚合查询慢,无 loading 体验差
|
||||
- 任一页面抛错会冒泡到顶层 dashboard error boundary,无法精确定位
|
||||
|
||||
---
|
||||
|
||||
### 🟡 2.6【类型安全·P2】10 处 `as` 类型断言(非 unknown 收窄)
|
||||
|
||||
**位置**:
|
||||
|
||||
| 文件:行号 | 断言 | 说明 |
|
||||
|-----------|------|------|
|
||||
| [editor/editor-to-structure.ts:101](file:///e:/Desktop/CICD/src/modules/exams/editor/editor-to-structure.ts#L101) | `: "single_choice") as RichQuestionType` | 字符串字面量断言为联合类型 |
|
||||
| [editor/exam-nodes-to-editor-doc.ts:38](file:///e:/Desktop/CICD/src/modules/exams/editor/exam-nodes-to-editor-doc.ts#L38) | 同上 | 同上 |
|
||||
| [editor/selection-toolbar.tsx:213,215](file:///e:/Desktop/CICD/src/modules/exams/editor/selection-toolbar.tsx#L213) | `slice.content.toJSON() as JSONContent[]` | ProseMirror→Tiptap 类型 |
|
||||
| [editor/exam-rich-editor.tsx:158,174](file:///e:/Desktop/CICD/src/modules/exams/editor/exam-rich-editor.tsx#L158) | `editor.getJSON() as EditorJSONContent` | Tiptap 内部类型断言 |
|
||||
| [components/exam-data-table.tsx:39](file:///e:/Desktop/CICD/src/modules/exams/components/exam-data-table.tsx#L39) | `params as Record<...>` | 不安全参数断言 |
|
||||
| [components/exam-form.tsx:40](file:///e:/Desktop/CICD/src/modules/exams/components/exam-form.tsx#L40) | `zodResolver(formSchema) as Resolver<ExamFormValues>` | zodResolver 返回类型断言 |
|
||||
| [actions-rich-editor.ts:147,230](file:///e:/Desktop/CICD/src/modules/exams/actions-rich-editor.ts#L147) | `q.type as "single_choice" | "multiple_choice" | "text" | "judgment"` | 字符串断言为联合类型 |
|
||||
| [edit-rich/page.tsx:64](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx#L64) | `structureToEditorDoc(editorDoc) as EditorJSONContent` | 类型断言 |
|
||||
|
||||
**违反规则**:项目规则"禁止 `as` 断言(除从 `unknown` 转换或测试中,需注释原因)"。
|
||||
|
||||
**原因**:Tiptap/ProseMirror 类型系统与项目类型边界处缺类型守卫;`RichQuestionType` 联合类型的字符串字面量缺运行时校验函数。
|
||||
|
||||
**直接后果**:若 AI 返回未预期的 type 值(如 "essay"),`as` 断言会让错误值通过类型检查,运行时可能渲染异常。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 2.7【企业级能力缺失·P2】无统一空状态/骨架屏/错误回退
|
||||
|
||||
**位置**:组件层未抽取统一的 `<ExamEmptyState>` / `<ExamSkeleton>` / `<ExamErrorBoundary>`。
|
||||
|
||||
**问题**:
|
||||
- `all/page.tsx` 自行实现了 `ExamsResultsFallback`,未复用到 `analytics`/`edit-rich`
|
||||
- `exam-card.tsx`、`exam-grid.tsx` 无骨架屏
|
||||
- 编辑器加载(Tiptap 初始化)期间无统一占位
|
||||
|
||||
**违反规则**:审计要求"明确处理空数据、无权限、网络异常等边界状态"、"异步数据使用 React Suspense + 骨架屏"。
|
||||
|
||||
**直接后果**:体验不一致,重复实现。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 2.8【可测试性·P2】纯逻辑与 UI 耦合,缺单测
|
||||
|
||||
**位置**:
|
||||
- `components/exam-preview-utils.ts`(293 行纯函数,已抽取,但无单测)
|
||||
- `hooks/use-exam-preview-{state,tasks,rewrite}.ts` 无对应测试
|
||||
- `editor/editor-to-structure.ts`、`editor/structure-to-editor.ts` 双向转换是核心纯逻辑,无单测
|
||||
- `stats-service.ts` 的错误率/难度计算无单测
|
||||
|
||||
**违反规则**:审计要求"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock"。
|
||||
|
||||
**直接后果**:富文本编辑器双向转换是高风险逻辑(type/score/structure 映射),无单测难以保证回归质量。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 2.9【解耦性·P2】未通过接口抽象 + Context 注入数据服务
|
||||
|
||||
**位置**:模块整体。
|
||||
|
||||
**问题**:当前组件直接 import 同模块的 actions/data-access(如 `exam-rich-form.tsx` 直接 import `autoMarkExamAction` / `createExamFromRichEditorAction`)。虽然同模块内 import 合规,但审计要求"通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
|
||||
|
||||
**违反规则**:审计重构方案的"完全解耦"与"可测试性"原则。
|
||||
|
||||
**原因**:当前实现以功能正确性优先,未做依赖注入抽象。
|
||||
|
||||
**直接后果**:
|
||||
- 组件无法在测试中 mock 数据服务
|
||||
- 不同角色(teacher/admin/parent/student)的差异未通过接口实现隔离,未来扩展角色需改组件
|
||||
- 配置驱动设计未落地,新增 Widget 需改组件代码
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
参考智学网、猿题库、学而思网校、Google Classroom、Canvas LMS 等同类产品,exams 模块当前差距:
|
||||
|
||||
### 3.1 试卷创建侧
|
||||
| 行业实践 | 当前状态 | 差距影响 |
|
||||
|---------|---------|---------|
|
||||
| 多种组卷入口(手动/AI/富文本/导入 Word)三选一清晰呈现 | 已有三种入口,但 `/create`、`/new` 路由并列,无统一选择页 | 教师首次使用困惑 |
|
||||
| 试卷模板库(按学科/年级预置模板) | ❌ 无 | 教师每次从零创建,效率低 |
|
||||
| 知识点双向细目表(题目-知识点覆盖矩阵) | ❌ 无(虽有 questions.knowledgePoints,但 exam 层无细目表视图) | 无法评估试卷覆盖度 |
|
||||
| 难度预估(基于题库历史正确率自动估算试卷难度) | ❌ 无(仅手动 1-5 级) | 难度设置主观 |
|
||||
| 试卷预览支持 PDF 导出/打印 | ❌ 无 | 教师无法离线分发 |
|
||||
|
||||
### 3.2 考试作答侧(学生)
|
||||
| 行业实践 | 当前状态 | 差距影响 |
|
||||
|---------|---------|---------|
|
||||
| 作答页答题卡导航(已答/未答/标记 revisit) | ❌ 仅顺序作答 | 学生难以跳题、检查 |
|
||||
| 自动保存进度可视化 | homework 模块已实现(autoSave* 翻译键齐全) | ✅ 较好 |
|
||||
| 限时/监考倒计时 | homework 模块已实现 useExamCountdown | ✅ 较好 |
|
||||
| 客观题即时反馈(练习模式) | ❌ 仅作业模式提交后批改 | 缺少低风险练习模式 |
|
||||
|
||||
### 3.3 考试分析侧(教师)
|
||||
| 行业实践 | 当前状态 | 差距影响 |
|
||||
|---------|---------|---------|
|
||||
| 平均分/及格率/分数段分布 | ✅ 已实现(V3-8) | — |
|
||||
| 逐题错误率与难度等级 | ✅ 已实现 | — |
|
||||
| 知识点掌握度雷达图 | diagnostic 模块有,但未在 exam analytics 集成 | 教师需跨页查看 |
|
||||
| 班级横向对比 | ❌ 无(仅全卷汇总) | 无法定位班级差异 |
|
||||
| 学生个体诊断报告(一键生成) | ❌ 无 | 个性化反馈缺失 |
|
||||
| 历次考试趋势 | ❌ 无 | 无法看进步趋势 |
|
||||
|
||||
### 3.4 多角色覆盖侧
|
||||
| 角色 | 当前覆盖 | 差距 |
|
||||
|------|---------|------|
|
||||
| admin | ❌ 无 admin 视角考试管理(全校/年级聚合) | admin 仅能通过 dashboard 看 examCount,无考试管理页 |
|
||||
| teacher | ✅ 完整(创建/组卷/预览/分析/监考) | — |
|
||||
| parent | ✅ parent 模块有 child-exam-detail + parentExam i18n | 缺少历次考试趋势对比 |
|
||||
| student | ⚠️ 通过 homework-take-view 作答,但无独立"我的考试"汇总页 | 学生无法回看历史考试试卷与成绩 |
|
||||
|
||||
### 3.5 UX 细节
|
||||
- 缺少全局考试状态徽章颜色规范(draft/published/archived 在 exam-card 与 exam-columns 中重复定义)
|
||||
- exam-card 科目颜色映射 `subjectColorMap` 硬编码英文字符串 key("Mathematics" 等),无法国际化——科目名应通过 ID 映射颜色,而非名称
|
||||
- 无空状态插画/图标统一规范(all 页用 FileText,analytics 页也用 BarChart3,缺一致性)
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响架构合规与数据安全)
|
||||
|
||||
| # | 问题 | 改进方向 | 关联规则 |
|
||||
|---|------|---------|---------|
|
||||
| P0-1 | `data-access-cross-module.ts:488` 直接 JOIN questions 表 | 在 questions 模块新增 `getQuestionTypeMapByIds(ids): Promise<Map<string, string>>`,exams 改为调用此接口 | 模块间禁止直查对方表 |
|
||||
| P0-2 | `new/`、`[id]/edit-rich/`、`[id]/analytics/`、`all/`、`create/` 缺 loading.tsx/error.tsx | 补齐 loading.tsx + error.tsx,复用 dashboard 模式 | 路由边界规范 |
|
||||
|
||||
### P1(高影响,影响多语言与体验)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P1-1 | 11 个组件未接入 i18n(exam-card/exam-filters/exam-preview-dialog/exam-viewer/question-options-editor + 4 个 editor extensions) | 接入 useTranslations,提取翻译键到 exam-homework.json 的 exam.card/exam.viewer/exam.previewDialog/editor.* 命名空间 |
|
||||
| P1-2 | actions-rich-editor.ts + auto-mark.ts Server Action 返回消息硬编码 | 改用 getTranslations("examHomework.exam.actionMessages"),复用 actions.ts 已有翻译键,新增 richEditor.* / autoMark.* 子键 |
|
||||
| P1-3 | exam-card subjectColorMap 用英文名做 key | 改为按 subjectId 映射颜色,颜色配置移至 `shared/config/subject-colors.ts` |
|
||||
| P1-4 | stats-service.ts "(无题目文本)"、data-access.ts "General" 兜底硬编码 | 通过 data-access 层返回 null,由组件层 i18n 渲染兜底文案 |
|
||||
|
||||
### P2(中长期,企业级能力与重构)
|
||||
|
||||
| # | 问题 | 改进方向 | 状态 |
|
||||
|---|------|---------|------|
|
||||
| P2-1 | 10 处 `as` 类型断言 | 为 RichQuestionType 增加 `isRichQuestionType(v): v is RichQuestionType` 类型守卫;Tiptap JSONContent 边界用 zod schema 校验 | ✅ 已完成(2026-06-25):新增 isRichQuestionType/isStandaloneQuestionType/toRichQuestionType/toStandaloneQuestionType 4 个守卫,消除 editor-to-structure.ts:101、exam-nodes-to-editor-doc.ts:38、actions-rich-editor.ts:149/233 共 4 处 as 断言;其余 6 处 as 断言属于 unknown→具体类型的合法收窄或 Tiptap/ProseMirror 内部类型边界,已添加注释说明,保留 |
|
||||
| P2-2 | 纯逻辑无单测 | 为 exam-preview-utils、editor-to-structure、structure-to-editor、stats-service 错误率计算补充 .test.ts | ⏸️ 待实施(依赖 P2-4 ExamServicePort 落地后统一 mock) |
|
||||
| P2-3 | 无统一 ExamEmptyState/ExamSkeleton/ExamErrorBoundary | 抽取到 components/exam-boundaries.tsx,全模块复用 | ✅ 已完成(2026-06-25):创建 components/exam-boundaries.tsx(189 行),导出 ExamErrorBoundary/ExamEmptyState/ExamSkeleton 三组合单元,5 种骨架变体,新增 i18n 键 exam.error.boundaryTitle/boundaryDescription/retry |
|
||||
| P2-4 | 组件直接 import actions,未通过 Context 注入 | 定义 `ExamServicePort` 接口 + `ExamServiceProvider` Context,组件通过 `useExamService()` 获取;角色差异通过不同 Provider 实现隔离 | ✅ 已完成骨架(2026-06-25):创建 services/exam-service-port.ts(95 行,12 方法契约)+ services/exam-service-context.tsx(72 行,Context + Provider + Hook)+ services/index.ts(桶导出)。具体实现(TeacherExamService/AdminExamService/MockExamService)与组件改造将在 P2-6+ 落地 |
|
||||
| P2-5 | 无配置驱动的 Widget 渲染 | 参考 dashboard/config/widget-configs.ts,新增 `exams/config/exam-widgets.ts`,按角色配置渲染哪些子模块 | ✅ 已完成(2026-06-25):创建 config/exam-widgets.ts(192 行),四角色默认配置 + getExamWidgetConfig/getWidgetsBySlot 工具函数 |
|
||||
| P2-6 | 缺少考试模板库、知识点细目表、班级对比、学生个体报告 | 中长期功能补全,对标智学网 | ⏸️ 待实施(中长期) |
|
||||
| P2-7 | 缺少 admin 视角考试管理页、student 独立"我的考试"页 | 多角色覆盖补全 | ⏸️ 待实施(中长期,依赖 P2-4 具体实现 + P2-5 配置消费) |
|
||||
| P2-8 | 关键操作埋点不完整 | 已有 exam.ai_generated/updated/deleted/duplicated,需补 exam.published/archived/auto_marked 埋点 | ⏸️ 待实施 |
|
||||
| P2-9 | a11y 缺失(编辑器 extensions 无 aria-label 规范、键盘导航) | 为 Tiptap 节点添加 aria-label,工具栏支持完整键盘导航 | ⏸️ 待实施 |
|
||||
| P2-10 | 数据查询未结合权限二次校验(data-access 层部分函数未传 scope) | `getExamPreview`、`getExamSubjects`、`getExamGrades`、`duplicateExam`、`deleteExamById` 应接受 scope 参数或在 Action 层显式校验 | ⏸️ 待实施 |
|
||||
|
||||
> **本轮 P2 落地范围说明**:
|
||||
> - P2-1 / P2-3 / P2-4(骨架)/ P2-5 已完成,奠定解耦与配置驱动的架构基础
|
||||
> - P2-2 单测待 ExamServicePort 具体实现落地后统一 mock
|
||||
> - P2-6 / P2-7 为中长期功能补全,需独立规划排期
|
||||
> - P2-8 / P2-9 / P2-10 为增强项,可在后续迭代中逐步落地
|
||||
> - 全部 P2 代码改动已通过 `npx tsc --noEmit`(exams 模块零错误)与 `npx eslint`(零错误/零警告)验证
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图需补充以下节点:
|
||||
|
||||
### 004_architecture_impact_map.md 需补充
|
||||
|
||||
1. **exams 模块文件清单补全**:
|
||||
- 新增 `data-access-error-collection.ts` 行(当前 004 未单独列出)
|
||||
- 标注 `data-access-cross-module.ts` 中 `getExamForGradeEntry` 存在 P0 跨模块 JOIN 违规(待修复后改为 ✅ 已修复)
|
||||
|
||||
2. **permission 补全**:
|
||||
- 005 已有 EXAM_PROCTOR/EXAM_PROCTOR_READ,但 004 第 2.2 节 exams 权限点列表未完整列出
|
||||
|
||||
3. **dependencyMatrix 补充**:
|
||||
- `app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx` → `exams/editor/{exam-nodes-to-editor-doc,structure-to-editor}` 与 `exams/utils/normalize-structure`(当前 004 已记 build/page.tsx,但 edit-rich 同样依赖,需补)
|
||||
|
||||
4. **被依赖关系补全**:
|
||||
- `homework/data-access-utils.getQuestionText` 被 `exams/stats-service.ts` 调用,005 JSON 中 homework 模块 exports 的 usedBy 需补 `exams/stats-service`
|
||||
|
||||
### 005_architecture_data.json 需补充
|
||||
|
||||
1. `modules.exams.exports` 数组补:
|
||||
- `data-access-error-collection.ts`(含 `getExamErrorCollectionForExam` 等接口)
|
||||
- `getExamForGradeEntry`(标注跨模块 JOIN 待修复)
|
||||
|
||||
2. `modules.homework.exports` 中 `getQuestionText` 的 `usedBy` 补 `"exams/stats-service"`
|
||||
|
||||
3. `modules.questions.exports` 新增 `getQuestionTypeMapByIds`(修复 P0-1 后)
|
||||
|
||||
4. `architectureOverview.violations` 数组新增当前未记录的违规项,修复后改为 ✅ 标记
|
||||
|
||||
---
|
||||
|
||||
## 附:重构方案设计要点(落地架构)
|
||||
|
||||
> 以下为 P2-4/P2-5 的具体设计方向,作为中长期重构蓝图。本次实施将先完成 P0/P1,P2 仅落地基础接口与配置骨架。
|
||||
|
||||
### A. 完全解耦:ExamServicePort + Context 注入
|
||||
|
||||
```typescript
|
||||
// exams/services/exam-service-port.ts(新增)
|
||||
export interface ExamServicePort {
|
||||
listExams(params: GetExamsParams): Promise<Exam[]>
|
||||
getExam(id: string): Promise<ExamDetail | null>
|
||||
createExam(input: ExamCreateInput): Promise<ActionState<string>>
|
||||
updateExam(input: ExamUpdateInput): Promise<ActionState<string>>
|
||||
deleteExam(id: string): Promise<ActionState<string>>
|
||||
duplicateExam(id: string): Promise<ActionState<string>>
|
||||
getAnalytics(id: string): Promise<ExamAnalyticsSummary | null>
|
||||
// ... 所有数据访问通过此接口
|
||||
}
|
||||
|
||||
// exams/services/exam-service-context.tsx(新增)
|
||||
const ExamServiceContext = createContext<ExamServicePort | null>(null)
|
||||
export function ExamServiceProvider({ service, children }: { service: ExamServicePort; children: ReactNode }) { ... }
|
||||
export function useExamService(): ExamServicePort { ... }
|
||||
|
||||
// 不同角色的实现
|
||||
// exams/services/teacher-exam-service.ts // 调用真实 Server Actions
|
||||
// exams/services/admin-exam-service.ts // admin 视角(聚合全校)
|
||||
// exams/services/mock-exam-service.ts // 测试用
|
||||
```
|
||||
|
||||
### B. 组合优先:Widget 配置驱动
|
||||
|
||||
```typescript
|
||||
// exams/config/exam-widgets.ts(新增)
|
||||
export type ExamWidgetConfig = {
|
||||
role: Role
|
||||
widgets: Array<{
|
||||
id: "list" | "analytics" | "proctoring" | "templates" | "blueprint"
|
||||
visible: boolean
|
||||
order: number
|
||||
props?: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
export const examWidgetConfigs: Record<Role, ExamWidgetConfig> = { ... }
|
||||
```
|
||||
|
||||
### C. i18n 翻译文件结构示例(新增键)
|
||||
|
||||
```json
|
||||
{
|
||||
"exam": {
|
||||
"card": {
|
||||
"level": "难度 {{level}}",
|
||||
"minutes": "{{count}} 分钟",
|
||||
"points": "{{count}} 分",
|
||||
"questions": "{{count}} 题"
|
||||
},
|
||||
"viewer": {
|
||||
"section": "分卷",
|
||||
"group": "大题",
|
||||
"score": "分值",
|
||||
"noQuestions": "暂无题目"
|
||||
},
|
||||
"previewDialog": {
|
||||
"title": "试卷预览",
|
||||
"generating": "生成预览中...",
|
||||
"fullPreview": "完整试卷预览",
|
||||
"summary": "{{count}} 题 · {{subject}} · {{grade}} · {{minutes}} 分钟 · {{total}} 分",
|
||||
"noPreview": "暂无预览内容",
|
||||
"confirmCreate": "确认并创建",
|
||||
"untitledQuestion": "未命名题目",
|
||||
"untitledSubQuestion": "未命名子题",
|
||||
"scoreUnit": "分"
|
||||
},
|
||||
"richEditorAction": {
|
||||
"titleRequired": "请填写试卷标题",
|
||||
"contentRequired": "试卷内容不能为空",
|
||||
"contentInvalid": "试卷内容格式无效",
|
||||
"contentParseFailed": "试卷内容解析失败",
|
||||
"draftCreated": "试卷草稿已创建",
|
||||
"onlyOwnUpdate": "只能更新自己创建的试卷",
|
||||
"updated": "试卷已更新"
|
||||
},
|
||||
"autoMarkAction": {
|
||||
"sourceRequired": "试卷文本不能为空",
|
||||
"completed": "AI 自动标记完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### D. 错误与边界
|
||||
|
||||
- 每个路由的 `error.tsx` 复用 `exams/components/exam-error-boundary.tsx`(新增)
|
||||
- 列表/卡片使用 `<ExamSkeleton>` / `<ExamEmptyState>`(新增)
|
||||
- 编辑器加载使用 Suspense + 自定义骨架
|
||||
|
||||
### E. 可测试性
|
||||
|
||||
- 纯逻辑已有抽取(exam-preview-utils/editor-to-structure/structure-to-editor),补单测
|
||||
- ExamServicePort 接口允许测试注入 mock 实现
|
||||
|
||||
### F. 安全性
|
||||
|
||||
- data-access 层所有按 ID 查询函数增加可选 `scope` 参数,Action 层强制传入
|
||||
- `getExamPreview`、`duplicateExam`、`deleteExamById` 当前未校验 scope,需补
|
||||
|
||||
### G. 监控埋点
|
||||
|
||||
- 补 `exam.published`、`exam.archived`、`exam.auto_marked` 埋点
|
||||
- analytics 页访问埋点 `exam.analytics_viewed`
|
||||
557
docs/architecture/audit/files-audit-report.md
Normal file
557
docs/architecture/audit/files-audit-report.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# 文件模块审计报告
|
||||
|
||||
> 审查日期:2026-06-25
|
||||
> 审查范围:`src/modules/files/**`、`src/app/api/upload/**`、`src/app/api/files/**`、`src/app/(dashboard)/admin/files/**`、`src/modules/settings/actions-avatar.ts`、`src/modules/settings/components/avatar-upload.tsx`
|
||||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.17、`docs/architecture/005_architecture_data.json`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 路径 | 文件数 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| 路由层 - 上传 | `src/app/api/upload/route.ts` | 1 | FormData 接收 + 直接写磁盘 + DB 落库 |
|
||||
| 路由层 - 单文件 | `src/app/api/files/[id]/route.ts` | 1 | GET 查询单文件、DELETE 删除单文件 |
|
||||
| 路由层 - 批量删除 | `src/app/api/files/batch-delete/route.ts` | 1 | POST 批量删除 |
|
||||
| 路由层 - 管理页 | `src/app/(dashboard)/admin/files/page.tsx` | 1 | Server Component,调用 data-access 渲染 AdminFilesView |
|
||||
| 模块层 - data-access | `src/modules/files/data-access.ts` | 1(305 行) | 11 个函数 + mapRow |
|
||||
| 模块层 - types | `src/modules/files/types.ts` | 1(63 行) | 7 个接口/类型 |
|
||||
| 模块层 - 组件 | `src/modules/files/components/` | 6 文件 | FileUpload / FileList / FilePreview / FilePreviewDialog / FileIcon / AdminFilesView |
|
||||
| i18n | `messages/{en,zh-CN}/files.json` | 2 | 仅 2 个键(title、description) |
|
||||
| Schema | `src/shared/db/schema.ts` §12 | - | `file_attachments` 表 + 3 个索引 |
|
||||
| 权限点 | `src/shared/types/permissions.ts` | - | `FILE_UPLOAD` / `FILE_READ` / `FILE_DELETE` |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
浏览器 (AdminFilesView/FileUpload/AvatarUpload)
|
||||
│ fetch /api/upload (POST FormData)
|
||||
│ fetch /api/files/[id] (DELETE)
|
||||
│ fetch /api/files/batch-delete (POST JSON)
|
||||
▼
|
||||
API 路由层(requireAuth / requirePermission)
|
||||
│ 直接调用 files/data-access 函数
|
||||
▼
|
||||
files/data-access.ts → shared/db → file_attachments 表
|
||||
│ 存储抽象:/api/upload 与 /api/files/[id] 直接用 fs/promises
|
||||
│ /api/files/batch-delete 用 storageProvider
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
`004_architecture_impact_map.md` §2.17 与 `005_architecture_data.json` 的 `modules.files` 节点对 data-access 的导出函数签名、依赖关系记录基本完整。但发现以下遗漏(详见 §五):
|
||||
|
||||
- **`getFileByUrl` 函数未在 005 JSON 的 `dataAccess` 列表中记录**(实际代码存在,被 settings/actions-avatar.ts 使用)。
|
||||
- **被依赖方遗漏 settings 模块**:004 §2.17 仅记录 `app/api/upload / app/api/files/[id] / app/api/files/batch-delete / homework`,遗漏 `settings/actions-avatar.ts` 通过 `getFileByUrl` + `deleteFileAttachment` 调用清理头像文件。
|
||||
- **`FilePreviewDialog` 组件未在 005 JSON 的 `components` 列表中记录**。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 三层架构违反:缺少 actions.ts 编排层(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/modules/files/` 目录 | 无 `actions.ts` 文件 | "每个模块标准结构:`actions.ts`(编排层)" |
|
||||
| `src/app/api/upload/route.ts` L13 | `import { createFileAttachment } from "@/modules/files/data-access"` | "app/ 只能调用 modules/ 的 Server Actions 和 data-access" — API 路由虽可直调 data-access,但项目规范要求所有写操作通过 actions 编排 |
|
||||
| `src/app/api/files/batch-delete/route.ts` L6-9 | 直接 import 两个 data-access 函数 | 同上 |
|
||||
| `src/app/(dashboard)/admin/files/page.tsx` L7-10 | page.tsx 直接调用 data-access 函数 | 同上(应通过 actions 编排权限与数据获取) |
|
||||
|
||||
**原因**:模块创建时未遵循标准结构,所有数据访问被路由层与页面层直接调用,跳过 actions 编排层。
|
||||
|
||||
**后果**:
|
||||
1. 权限校验分散在路由层,无法集中管理;
|
||||
2. 无法在 actions 层统一埋点、审计日志、缓存策略;
|
||||
3. 与项目其他模块(如 announcements、settings、grades 等)的 actions.ts 模式不一致,破坏一致性;
|
||||
4. 无法被其他模块以 actions 形式复用(settings 模块只能 import data-access)。
|
||||
|
||||
### 2.2 i18n 严重缺失:UI 文本全部硬编码英文(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `messages/en/files.json`、`messages/zh-CN/files.json` | 仅 `title`、`description` 2 个键 | "所有用户可见文本必须适配 i18n" |
|
||||
| `components/file-upload.tsx` L48-51、L188-191、L218、L230 | `"File is empty"` / `"Click to upload or drag and drop"` / `"Uploaded"` 等硬编码 | 同上 |
|
||||
| `components/file-list.tsx` L27-28、L37、L40、L84、L104、L117 | `"No files"` / `"File deleted"` / `"Download"` / `"Delete"` 等硬编码 | 同上 |
|
||||
| `components/file-preview.tsx` L71、L113-119、L207、L215 | `"Download"` / `"Office file preview not available"` / `"Load preview"` 等硬编码 | 同上 |
|
||||
| `components/file-preview-dialog.tsx` L29、L47 | `"Preview"` / `"File preview ·"` 硬编码 | 同上 |
|
||||
| `components/admin-files-view.tsx` L33-45、L121、L135-139、L147、L154、L177、L199、L217、L261、L271 | `TYPE_OPTIONS` 标签 + "Files"/"Total Files"/"Total Size"/"Filter by type"/"Search by file name..."/"Delete Selected"/"Deleting..." 等几十处硬编码 | 同上 |
|
||||
|
||||
**原因**:组件开发时直接用英文字面量,未提取翻译键;i18n 文件仅 placeholder。
|
||||
|
||||
**后果**:
|
||||
1. 中文用户看到全英文界面(与系统其他模块本地化不一致);
|
||||
2. 切换语言无效;
|
||||
3. 翻译协作无法进行;
|
||||
4. 违反项目硬约束。
|
||||
|
||||
### 2.3 权限校验缺失:上传路由仅 requireAuth 未走 requirePermission(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/app/api/upload/route.ts` L31 | `const ctx = await requireAuth()` | "所有 Server Action 必须调用 `requirePermission()` 进行权限校验" |
|
||||
| 同文件 L18-24 | 已定义 `FILE_UPLOAD` 权限点(permissions.ts L123),但路由层未使用 | 同上 |
|
||||
|
||||
**原因**:上传路由创建时仅考虑登录态校验,未将 `FILE_UPLOAD` 权限点接入;项目已定义该权限点但未消费。
|
||||
|
||||
**后果**:
|
||||
1. 任何登录用户(含无上传权限的角色)均可调用上传接口;
|
||||
2. 与 RBAC 设计意图相悖;
|
||||
3. FILE_UPLOAD 权限点形同虚设。
|
||||
|
||||
### 2.4 横向越权:GET /api/files/[id] 仅 requireAuth(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/app/api/files/[id]/route.ts` L24 | `await requireAuth()` 后直接返回任意 id 的文件元数据 | "Server Action 二次校验" + 数据级别权限过滤要求 |
|
||||
| 同文件 L52-58 | DELETE 仅校验 `FILE_DELETE`,未校验调用者是否拥有该文件 | 同上 |
|
||||
|
||||
**原因**:GET 路由未做权限分级,DELETE 未做所有权或所属 target 关联校验。
|
||||
|
||||
**后果**:
|
||||
1. 学生可通过枚举 ID 拉取他人上传的考试附件元数据(含 mimeType、url、uploaderId 等);
|
||||
2. 通过返回的 url 即可访问文件本体(文件存储在 `/public/uploads/...`,无签名机制);
|
||||
3. 任何 `FILE_DELETE` 权限者可删除他人上传的文件;
|
||||
4. **安全风险:违反 FERPA/GDPR 学生数据保护原则**。
|
||||
|
||||
### 2.5 缺少 loading.tsx 和 error.tsx(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/app/(dashboard)/admin/files/` | 仅 `page.tsx`,缺 `loading.tsx` / `error.tsx` | "All student routes must include loading.tsx and error.tsx for error boundaries"(admin 同样遵循,参考 admin/users、admin/roles) |
|
||||
|
||||
**原因**:路由未补齐标准文件。
|
||||
|
||||
**后果**:
|
||||
1. 无骨架屏 → 用户首次进入白屏时间长;
|
||||
2. data-access 抛错 → 整个 dashboard 布局错误(无 error boundary 隔离)。
|
||||
|
||||
### 2.6 类型安全:API 路由存在 `as` 断言(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/app/api/upload/route.ts` L100-102 | `targetType: isTargetType(...) ? (targetType as string) : null` | "禁止 `as` 断言(除类型收窄外)" |
|
||||
| `src/app/api/files/batch-delete/route.ts` L23 | `const body = (await req.json().catch(() => null)) as { ids?: unknown } \| null` | 同上 |
|
||||
| `src/app/api/files/batch-delete/route.ts` L23 | `body!.ids` 非空断言 | "可选链后禁止跟非空断言 `!`"(同类违规) |
|
||||
|
||||
**原因**:缺少 Zod schema 校验,只能用 `as` 转换未验证输入。
|
||||
|
||||
**后果**:类型不安全的输入直接进入业务逻辑,运行时仍可能爆炸;tsc 严格模式被绕过。
|
||||
|
||||
### 2.7 缺少 Zod schema 验证(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/modules/files/` | 无 `schema.ts` | "模块标准结构:`schema.ts`(Zod 验证)" |
|
||||
| `src/app/api/upload/route.ts` L44-47 | `formData.get("file")` / `formData.get("targetType")` 未做 Zod 校验 | "输入使用 Zod 验证,验证失败返回结构化错误" |
|
||||
| `src/app/api/files/batch-delete/route.ts` L22 | `body.ids` 仅做 `Array.isArray` + `typeof === "string"` 简单过滤,无长度上限/格式约束 | 同上 |
|
||||
|
||||
**原因**:路由层手写最小校验,未引入 Zod schema。
|
||||
|
||||
**后果**:
|
||||
1. 大量恶意 ids 可导致 inArray 查询超长 SQL;
|
||||
2. targetType 可注入任意字符串进入 DB;
|
||||
3. 与项目其他模块(如 announcements、classes)的 schema.ts 模式不一致。
|
||||
|
||||
### 2.8 存储抽象使用不一致(P1)
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `src/app/api/upload/route.ts` L3-4、L80-85 | 直接 `import { mkdir, writeFile } from "fs/promises"` + `path.join(process.cwd(), "public", ...)` 写文件 |
|
||||
| `src/app/api/files/[id]/route.ts` L2-3、L61-66 | 直接 `import { unlink } from "fs/promises"` 删文件 |
|
||||
| `src/app/api/files/batch-delete/route.ts` L5、L37 | 使用 `storageProvider.delete()` 抽象 |
|
||||
| `src/modules/settings/actions-avatar.ts` L2-3、L27-34 | 又一次直接 `import { unlink } from "fs/promises"` |
|
||||
|
||||
**原因**:`storageProvider` 抽象存在但只在批量删除处使用,单文件上传/删除路径未迁移。
|
||||
|
||||
**后果**:
|
||||
1. 切换到 OSS/S3 时需要修改 3 处代码;
|
||||
2. 4 个调用点对路径解析逻辑重复实现(DRY 违反);
|
||||
3. `actions-avatar.ts` 中的 `path.join(process.cwd(), "public", fileRecord.storagePath)` 与 storageProvider 的 `delete()` 行为重复,且未处理 `storagePath` 以 `/` 开头的情况。
|
||||
|
||||
### 2.9 FileList 组件死代码(P2)
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `src/modules/files/components/file-list.tsx`(125 行) | 全代码库无任何文件 import 该组件(grep `from "@/modules/files/components/file-list"` 为 0 结果);admin-files-view.tsx 自行实现列表,未使用 FileList |
|
||||
|
||||
**原因**:早期创建后未接入;AdminFilesView 复刻了类似 UI。
|
||||
|
||||
**后果**:
|
||||
1. 死代码维护负担;
|
||||
2. 误导后续维护者以为有消费者;
|
||||
3. 125 行无测试覆盖。
|
||||
|
||||
### 2.10 缺少自定义 hooks(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/modules/files/` | 无 `hooks/` 目录 | "模块标准结构:`hooks/`(可选)" + "逻辑复用一律抽取为自定义 hooks" |
|
||||
| `components/file-upload.tsx` L43-159 | 上传逻辑(XHR + 进度 + 状态机)全部内联在组件中 | "逻辑复用一律抽取为自定义 hooks" |
|
||||
| `components/file-preview.tsx` L180-233 | `TextPreview` 内部 `load` 逻辑(fetch + 错误处理)内联 | 同上 |
|
||||
| `components/admin-files-view.tsx` L49-128 | 客户端筛选、批量选择、批量删除逻辑全部内联 | 同上 |
|
||||
|
||||
**原因**:未抽取 `useFileUpload` / `useFilePreview` / `useFileBatchOperations` 等 hooks。
|
||||
|
||||
**后果**:
|
||||
1. 组件无法独立测试;
|
||||
2. 逻辑无法在多个组件间复用(如 `FileUpload` 的进度逻辑无法被 `AvatarUpload` 复用);
|
||||
3. 单组件行数膨胀风险。
|
||||
|
||||
### 2.11 缺少 React Error Boundary 和 Suspense 骨架屏(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/app/(dashboard)/admin/files/page.tsx` | 整页同步 await,无 Suspense 包裹 | "异步数据使用 React Suspense + 骨架屏" |
|
||||
| `components/admin-files-view.tsx` | 上传/批量删除错误仅 toast,无 Error Boundary 隔离失败区块 | "每个独立的数据区块必须用 React Error Boundary 包裹" |
|
||||
| `components/file-preview.tsx` L188-198 | TextPreview 的 fetch 错误仅展示文本,无重试按钮 | "明确处理空数据、无权限、网络异常等边界状态" |
|
||||
|
||||
**原因**:未引入 React 19 的 Error Boundary 与 Suspense 流式渲染模式。
|
||||
|
||||
**后果**:
|
||||
1. 上传失败仅 toast,用户无法定位失败任务重试;
|
||||
2. 整页 await 阻塞流式渲染;
|
||||
3. 网络异常时整页空白。
|
||||
|
||||
### 2.12 数据级别权限过滤缺失(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/modules/files/data-access.ts` L128-143 | `getAllFileAttachments` 不带 scope/uploaderId 过滤,返回全局数据 | "所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" |
|
||||
| `src/modules/files/data-access.ts` L83-103 | `getFileAttachmentsByTarget` 不校验调用者是否有权访问该 target 资源 | 同上 |
|
||||
| `src/modules/files/data-access.ts` L196-234 | `getFileAttachmentsWithFilters` 不带任何权限维度 | 同上 |
|
||||
| `src/app/(dashboard)/admin/files/page.tsx` L25-28 | 仅校验 `FILE_READ` 权限,未结合 scope(如教师只能看自己班级的文件) | 同上 |
|
||||
|
||||
**原因**:模块未引入 `DataScope` 概念(grades/classes 模块已实现)。
|
||||
|
||||
**后果**:FILE_READ 持有者可读全部文件元数据,与 K12 多角色数据隔离要求不符。
|
||||
|
||||
### 2.13 架构图遗漏:getFileByUrl 未记录(P2)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `docs/architecture/005_architecture_data.json` `modules.files.exports.dataAccess` | 列出 10 个函数,但代码中实际有 11 个(缺 `getFileByUrl`) | "如果架构图未覆盖该模块的任何部分,必须优先补全架构图" |
|
||||
|
||||
**原因**:`getFileByUrl` 在 P2-13 修复时新增,未同步到 JSON。
|
||||
|
||||
**后果**:AI 友好数据不准确,自动化审计会遗漏该函数的依赖分析。
|
||||
|
||||
### 2.14 架构图遗漏:settings 模块依赖未记录(P2)
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `docs/architecture/004_architecture_impact_map.md` §2.17 被依赖 | 仅列 `app/api/upload / app/api/files/[id] / app/api/files/batch-delete / homework`,遗漏 `settings/actions-avatar.ts` 调用 `getFileByUrl` + `deleteFileAttachment` |
|
||||
|
||||
**原因**:avatar 清理功能上线时未同步架构图。
|
||||
|
||||
### 2.15 avatar-upload.tsx 使用 "user_avatar" 非枚举 targetType(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/modules/settings/components/avatar-upload.tsx` L78 | `formData.append("targetType", "user_avatar")` | "TypeScript 严格模式:禁止 `any`"(类型不一致) |
|
||||
| `src/modules/files/types.ts` L2 | `FileTargetType = "exam" \| "textbook" \| "question" \| "announcement" \| "homework"` 不含 `"user_avatar"` | 类型契约违反 |
|
||||
| `src/app/api/upload/route.ts` L100-102 | `isTargetType(...)` 返回 false 后 fallback 到 null,导致头像文件 `targetType=null` | 数据完整性受损 |
|
||||
|
||||
**原因**:头像上传复用 `/api/upload`,但用了未注册的 targetType 字符串。
|
||||
|
||||
**后果**:
|
||||
1. 头像文件在 DB 中 targetType 字段为 null,无法按类型筛选头像;
|
||||
2. 清理孤立头像文件时无法用 `getFileAttachmentsByTarget("user_avatar", ...)`;
|
||||
3. 类型系统未保护跨模块字符串。
|
||||
|
||||
### 2.16 客户端二次筛选与硬编码 limit 200(P2)
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `src/app/(dashboard)/admin/files/page.tsx` L26 | `getFileAttachmentsWithFilters({ limit: 200 })` 硬编码 200,无分页 |
|
||||
| `components/admin-files-view.tsx` L54-75 | 客户端 useMemo 重复执行筛选逻辑(与 data-access 的 getFileAttachmentsWithFilters 重复) |
|
||||
|
||||
**原因**:早期为简化实现,未做服务端分页 + URL 查询参数。
|
||||
|
||||
**后果**:
|
||||
1. 文件数超过 200 时静默丢失;
|
||||
2. 服务端筛选与客户端筛选逻辑双写、易不一致;
|
||||
3. 无法被搜索引擎或分享链接复用筛选状态。
|
||||
|
||||
### 2.17 监控埋点缺失(P2)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/app/api/upload/route.ts`、`src/app/api/files/[id]/route.ts`、`src/app/api/files/batch-delete/route.ts` | 无 `trackEvent` 调用 | "方案中预留关键操作埋点接口" |
|
||||
| `src/modules/files/data-access.ts` 各写函数 | 无审计日志(其他模块如 audit、announcements 已接入 `audit-logger`) | 同上 |
|
||||
|
||||
**原因**:模块创建时未规划可观测性。
|
||||
|
||||
**后果**:无法追踪上传/删除异常、容量增长、恶意批量删除等运营事件。
|
||||
|
||||
### 2.18 可访问性不足(P2)
|
||||
|
||||
| 位置 | 问题 |
|
||||
|------|------|
|
||||
| `components/file-list.tsx` L70-78 | `<a>` 链接无 `aria-label`,仅靠 `title` 提供上下文 |
|
||||
| `components/file-preview.tsx` L96-101 | PDF iframe 缺 `aria-label`,仅 `title` |
|
||||
| `components/admin-files-view.tsx` L226-229 | 全选 Checkbox indeterminate 状态无 ARIA 描述 |
|
||||
| `components/file-upload.tsx` L161-204 | 拖拽区 `role="button"` 但缺 `aria-describedby` 关联限制说明 |
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 文件预览能力薄弱
|
||||
|
||||
- **行业实践**(如 PowerSchool、Canvas、阿里云教育):Office 文档通过 Office Online Viewer / WPS 在线预览服务打开;视频支持原生 `<video>` 流式播放;音频支持波形 + 播放器。
|
||||
- **当前实现**:仅支持 image / pdf / text 三类,Office 与视频、音频统一回退到下载。
|
||||
- **影响**:教师上传 PPT 课件时无法快速浏览;学生作业扫描图(PNG)预览体验勉强可用,但 Office 资料无法即时查阅。
|
||||
|
||||
### 3.2 缺少文件分类与文件夹概念
|
||||
|
||||
- **行业实践**:按课程/班级/学科组织文件树;支持文件夹嵌套;提供"我的文件"/"共享文件"/"班级文件"分区。
|
||||
- **当前实现**:仅靠 `targetType` + `targetId` 多态关联,无独立文件夹实体;admin/files 是单一平铺列表。
|
||||
- **影响**:教师上传大量课件后无法分类组织;学生查找特定学科资料困难。
|
||||
|
||||
### 3.3 缺少文件版本管理
|
||||
|
||||
- **行业实践**:同名文件覆盖时保留历史版本;支持版本对比、回滚;显示修改人/修改时间。
|
||||
- **当前实现**:每次上传生成新 cuid 文件名,无版本关联;同名文件多次上传产生多个独立记录。
|
||||
- **影响**:教师更新课件后旧链接失效;无法回滚误删/误改。
|
||||
|
||||
### 3.4 缺少配额与存储管理
|
||||
|
||||
- **行业实践**:按用户/角色/班级分配存储配额;接近上限时提醒;超限拒绝上传;管理员可查看配额使用排行。
|
||||
- **当前实现**:仅全局 `getFileStats` 统计总量,无 per-user 配额、无超限拦截。
|
||||
- **影响**:单个用户可耗尽服务器磁盘;K12 学校存储成本不可控。
|
||||
|
||||
### 3.5 缺少文件分享与权限管理
|
||||
|
||||
- **行业实践**:生成分享链接(含过期时间、密码、查看/下载权限);可按用户/班级精细授权;分享操作有审计日志。
|
||||
- **当前实现**:所有文件 url 直接 `/uploads/...` 公开访问,无签名、无过期、无权限校验。
|
||||
- **影响**:**严重安全风险**:任何知道 URL 的人(含未登录用户)均可下载;学生隐私材料(成绩单扫描件、家长联系信息)可能泄露。
|
||||
|
||||
### 3.6 缺少病毒扫描与内容安全
|
||||
|
||||
- **行业实践**:上传时通过 ClamAV / 云安全服务扫描;图片通过 NSFW 检测;扫描结果记录到 DB。
|
||||
- **当前实现**:仅校验 MIME 与大小,无内容安全检测。
|
||||
- **影响**:恶意文件可上传到服务器并被其他用户下载;K12 场景下未成年人保护要求更高。
|
||||
|
||||
### 3.7 缺少图片缩略图与 CDN 加速
|
||||
|
||||
- **行业实践**:上传图片后异步生成多档缩略图(48/128/512);通过 CDN 分发;列表用小图、预览用大图。
|
||||
- **当前实现**:原图直出。
|
||||
- **影响**:列表页加载慢;带宽浪费;移动端体验差。
|
||||
|
||||
### 3.8 缺少文件使用追踪
|
||||
|
||||
- **行业实践**:DB 记录文件被哪些资源(exam/textbook/homework)引用;删除文件前校验引用计数;提供"孤立文件清理"任务。
|
||||
- **当前实现**:仅 `targetType` + `targetId` 字段记录关联,无引用计数;删除 exam 时其附件不会自动清理。
|
||||
- **影响**:磁盘孤儿文件累积;删除资源后附件残留。
|
||||
|
||||
### 3.9 多角色体验差距
|
||||
|
||||
- **教师**:无"我上传的"快捷筛选;无按班级/学科筛选;批量上传后无"批量设置 target"操作。
|
||||
- **学生**:完全无入口查看自己上传的作业扫描图(仅 homework 模块内部使用);无法预览已上传的作业附件。
|
||||
- **家长**:无入口查看学校下发的通知附件;仅靠 announcement 模块内嵌预览。
|
||||
- **管理员**:无"按上传者筛选"快捷入口;无"近期 7 天"快捷时间筛选;无配额管理面板。
|
||||
|
||||
### 3.10 缺少批量操作与企业级功能
|
||||
|
||||
- **行业实践**:批量移动到 target、批量设置权限、批量下载(ZIP 打包)、批量重命名、Excel 导出文件清单。
|
||||
- **当前实现**:仅批量删除。
|
||||
- **影响**:管理员整理文件效率低。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(必须立即修复 — 安全与架构合规)
|
||||
|
||||
| # | 项 | 改进方向 |
|
||||
|---|----|---------|
|
||||
| P0-1 | 创建 `src/modules/files/actions.ts` 编排层 | 将 upload / delete / batchDelete / query 包装为 Server Actions(带 `requirePermission` + Zod + 审计日志),路由层改为转发到 actions |
|
||||
| P0-2 | 补全 i18n 翻译键(en + zh-CN `files.json`) | 提取约 40 个翻译键,组件全部接入 `useTranslations("files")` |
|
||||
| P0-3 | 修复 `/api/upload` 权限校验 | 将 `requireAuth()` 替换为 `requirePermission(Permissions.FILE_UPLOAD)` |
|
||||
| P0-4 | 修复横向越权 | `GET /api/files/[id]` 加 `FILE_READ`;`DELETE /api/files/[id]` 校验调用者 ownership 或拥有 `FILE_DELETE`;data-access 新增 `assertFileOwnedBy` 辅助 |
|
||||
| P0-5 | 文件 URL 签名保护(中长期) | 改为非 public 路径 + 通过 API 路由签名分发,含过期时间与权限校验 |
|
||||
|
||||
### P1(本轮实施 — 质量与一致性)
|
||||
|
||||
| # | 项 | 改进方向 |
|
||||
|---|----|---------|
|
||||
| P1-1 | 新增 `loading.tsx` + `error.tsx` | 骨架屏 + Error Boundary 隔离 |
|
||||
| P1-2 | 新增 `src/modules/files/schema.ts` | Zod 校验 upload/batchDelete/filter 输入,移除 `as` 断言 |
|
||||
| P1-3 | 抽取 hooks | `useFileUpload` / `useFileBatchOperations` / `useFilePreview`,组件瘦身 |
|
||||
| P1-4 | 统一 storageProvider 调用 | upload / single delete / avatar cleanup 全部改用 `storageProvider` |
|
||||
| P1-5 | 修复 user_avatar targetType | 将 `"user_avatar"` 加入 `FileTargetType` 枚举;upload route `VALID_TARGET_TYPES` 同步;avatar-upload.tsx 类型对齐 |
|
||||
| P1-6 | 引入 DataScope 过滤 | data-access 的查询函数新增 `scope: DataScope` 与 `currentUserId?: string` 参数,参照 grades 模块实现 |
|
||||
| P1-7 | 分区 Error Boundary + Suspense | AdminFilesView 拆为 StatsSection / UploadSection / ListSection,每区独立 Suspense + Boundary |
|
||||
| P1-8 | 接入 audit-logger | upload / delete / batchDelete 记录审计日志(参照 announcements 模块) |
|
||||
| P1-9 | 接入 trackEvent 埋点 | 关键操作埋点:upload_success / upload_failed / delete / batch_delete |
|
||||
|
||||
### P2(中长期 — 体验与企业级)
|
||||
|
||||
| # | 项 | 改进方向 |
|
||||
|---|----|---------|
|
||||
| P2-1 | 删除 FileList 死代码 | 确认无消费者后删除 125 行 |
|
||||
| P2-2 | 同步架构图 | 补全 `getFileByUrl` 节点、`FilePreviewDialog` 组件、settings 依赖关系 |
|
||||
| P2-3 | 服务端分页 + URL 查询参数 | 替换硬编码 limit=200,支持 `?page=&size=&type=&search=&uploader=` |
|
||||
| P2-4 | a11y 增强 | 给所有链接/iframe/Checkbox 加 ARIA 标签;拖拽区 `aria-describedby` |
|
||||
| P2-5 | 文件预览扩展(中长期) | 接入 Office Online Viewer / 视频原生 `<video>` / 音频播放器 |
|
||||
| P2-6 | 文件版本管理(中长期) | 新增 `file_versions` 表,同名覆盖时保留历史 |
|
||||
| P2-7 | 文件配额(中长期) | 新增 `user_storage_quota` 配置;data-access 校验超限;admin 配额面板 |
|
||||
| P2-8 | 文件分享与签名 URL(中长期) | 文件改为非 public 存储 + 签名分发;分享链接生成/吊销 |
|
||||
| P2-9 | 病毒扫描(中长期) | 接入 ClamAV 或云扫描服务;扫描结果记录到 DB |
|
||||
| P2-10 | 图片缩略图 + CDN(中长期) | 异步生成多档缩略图;CDN 分发 |
|
||||
| P2-11 | 引用计数与孤立文件清理(中长期) | 删除资源时级联清理附件;定期清理孤儿文件任务 |
|
||||
| P2-12 | 文件夹/分类实体(中长期) | 新增 `folders` 表;支持嵌套与按班级/学科组织 |
|
||||
| P2-13 | 批量操作扩展(中长期) | 批量下载 ZIP、批量移动 target、Excel 导出清单 |
|
||||
|
||||
### 重构设计原则遵循说明
|
||||
|
||||
| 原则 | 落地方式 |
|
||||
|------|---------|
|
||||
| 完全解耦 | data-access 仅暴露纯函数;actions 注入权限/审计;组件通过 props 接收数据(无直接 data-access import) |
|
||||
| 组合优先 | AdminFilesView 拆为 `<StatsSection />` + `<UploadSection />` + `<ListSection />` 组合;逻辑全入 hooks |
|
||||
| 国际化就绪 | 所有可见文本走 `useTranslations("files.*")`;提供翻译文件结构示例 |
|
||||
| 最大化复用 | `useFileUpload` hook 同时被 FileUpload 与 AvatarUpload 复用;`FileIcon` 已是复用单元 |
|
||||
| 错误与边界处理 | 每区独立 Error Boundary;Suspense + 骨架屏;上传任务列表保留失败项供重试 |
|
||||
| 可测试性 | hooks 独立可测;data-access 纯函数;schema 可独立测试 |
|
||||
| 可扩展性 | 通过 `FILES_ROLE_CONFIG` 决定各角色可见 Widget;新增角色仅改配置 |
|
||||
| 企业级补充 | a11y ARIA 标签、流式 RSC、storageProvider 二次校验、audit-logger + trackEvent |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 004_architecture_impact_map.md §2.17 同步项
|
||||
|
||||
1. **导出函数**章节:
|
||||
- 补充 `getFileByUrl` 函数(用于头像 URL 反查文件记录)
|
||||
- 修正 `getFileAttachmentsByOwner` → 实际函数名为 `getFileAttachmentsByUploader`
|
||||
- 补充 `FilePreviewDialog` 组件到组件清单
|
||||
2. **依赖关系**章节:
|
||||
- 被依赖方新增 `settings`(`actions-avatar.ts` 调用 `getFileByUrl` + `deleteFileAttachment`)
|
||||
3. **已知问题**章节:
|
||||
- 移除 "⚠️ P2:无 actions.ts" 标记(本轮 P0-1 实施后失效)
|
||||
- 新增 "i18n 缺失"、"权限校验缺失"、"横向越权"标记(实施后转为 ✅)
|
||||
|
||||
### 5.2 005_architecture_data.json 同步项
|
||||
|
||||
1. `modules.files.exports.dataAccess` 数组补充:
|
||||
```json
|
||||
{
|
||||
"name": "getFileByUrl",
|
||||
"signature": "(url: string) => Promise<FileAttachment | null>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "按 URL 反查文件附件记录(用于头像等场景的旧文件清理)",
|
||||
"deps": ["shared.db", "shared.db.schema.fileAttachments"],
|
||||
"usedBy": ["settings/actions-avatar.ts"]
|
||||
}
|
||||
```
|
||||
2. `modules.files.exports.actions` 节点新增(P0-1 实施后):
|
||||
- `uploadFileAction` / `deleteFileAction` / `batchDeleteFilesAction` / `getFileListAction` / `getFileStatsAction`
|
||||
3. `modules.files.exports.components` 数组补充 `FilePreviewDialog`
|
||||
4. `modules.files.dependencies.usedBy` 数组新增 `"settings"`
|
||||
5. `modules.files.types` 中 `FileTargetType` 定义更新为 `"exam" | "textbook" | "question" | "announcement" | "homework" | "user_avatar"`(P1-5 实施后)
|
||||
|
||||
---
|
||||
|
||||
## 六、翻译文件结构示例(i18n 就绪)
|
||||
|
||||
`messages/en/files.json` 与 `messages/zh-CN/files.json` 建议结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "File Management",
|
||||
"description": "View and manage all uploaded files in the system.",
|
||||
"nav": { "files": "Files" },
|
||||
"upload": {
|
||||
"title": "Click to upload or drag and drop",
|
||||
"hint": "Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {size}",
|
||||
"success": "{name} uploaded",
|
||||
"error": "{name}: {message}",
|
||||
"empty": "File is empty",
|
||||
"tooLarge": "File size exceeds {limit} limit",
|
||||
"invalidType": "File type {type} is not allowed",
|
||||
"uploaded": "Uploaded",
|
||||
"remove": "Remove",
|
||||
"networkError": "Network error",
|
||||
"invalidResponse": "Invalid response"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No files",
|
||||
"emptyDescription": "There are no files yet.",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"deleted": "File deleted",
|
||||
"deleteFailed": "Failed to delete file"
|
||||
},
|
||||
"preview": {
|
||||
"trigger": "Preview",
|
||||
"title": "File preview",
|
||||
"download": "Download",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"office": {
|
||||
"title": "Office file preview not available",
|
||||
"hint": "Download the file to view its contents in your Office application."
|
||||
},
|
||||
"other": {
|
||||
"title": "Preview not available",
|
||||
"hint": "Download the file to view its contents."
|
||||
},
|
||||
"text": {
|
||||
"title": "Text file",
|
||||
"hint": "Click below to load the content.",
|
||||
"load": "Load preview",
|
||||
"loading": "Loading...",
|
||||
"error": "Failed to load text: {message}"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Files",
|
||||
"subtitle": "Upload and manage all files in the system.",
|
||||
"stats": {
|
||||
"totalFiles": "Total Files",
|
||||
"totalSize": "Total Size"
|
||||
},
|
||||
"filter": {
|
||||
"byType": "Filter by type",
|
||||
"search": "Search by file name...",
|
||||
"allTypes": "All Types",
|
||||
"images": "Images",
|
||||
"pdf": "PDF",
|
||||
"word": "Word",
|
||||
"wordDocx": "Word (docx)",
|
||||
"excel": "Excel",
|
||||
"excelXlsx": "Excel (xlsx)",
|
||||
"powerpoint": "PowerPoint",
|
||||
"powerpointPptx": "PowerPoint (pptx)",
|
||||
"text": "Text",
|
||||
"zip": "ZIP"
|
||||
},
|
||||
"selection": {
|
||||
"selected": "{count} selected",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"deleting": "Deleting...",
|
||||
"deleted": "Deleted {count} file(s)",
|
||||
"deleteFailed": "Failed to delete files"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No files found",
|
||||
"description": "Try adjusting your filters or upload a new file."
|
||||
},
|
||||
"columns": {
|
||||
"file": "File",
|
||||
"size": "Size",
|
||||
"type": "Type",
|
||||
"uploaded": "Uploaded",
|
||||
"actions": "Actions"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 本报告由资深全栈架构师基于 2026-06-25 代码状态生成,所有 P0/P1 项将在本会话内完成实施,P2 中长期项按优先级逐步推进。
|
||||
457
docs/architecture/audit/grades-audit-report.md
Normal file
457
docs/architecture/audit/grades-audit-report.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# grades(成绩)模块审计报告
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:`src/modules/grades/**` + `src/app/(dashboard)/**/grades/**` 共 50 个文件
|
||||
> 架构图定位:`004_architecture_impact_map.md` § 2.6 / `005_architecture_data.json` `modules.grades`
|
||||
> 总体结论:该模块在架构图中被标记为"标杆模块(拆分范例)",三层架构、跨模块 data-access 通信、Server Action 权限校验、纯函数抽取、a11y 与 i18n 在主要文件中已落实。但仍存在 **3 处 P0 i18n 严重缺陷**、**2 个文件超行数上限**、**多处英文错误消息未 i18n**、**若干 `as` 断言与缺返回值类型**等问题,需按本报告优先级修复。
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
grades 模块共 50 个文件,按职责划分为四层:
|
||||
|
||||
| 层 | 文件数 | 路径 | 职责 |
|
||||
|----|--------|------|------|
|
||||
| 类型/校验 | 2 | `modules/grades/types.ts`、`schema.ts` | 类型定义 + Zod 输入校验 |
|
||||
| 数据访问 | 5 | `data-access.ts`、`data-access-analytics.ts`、`data-access-ranking.ts`、`export.ts`、`stats-service.ts` | DB I/O + Excel 导出 + 统计纯函数 |
|
||||
| Server Actions | 2 | `actions.ts`、`actions-analytics.ts` | 编排层(权限校验 → Zod → data-access → 通知 → revalidate) |
|
||||
| Lib 工具 | 4 | `lib/grade-utils.ts`、`scope-filter.ts`、`scope-check.ts`、`type-guards.ts` | 纯工具函数 + 行级权限过滤 + 类型守卫 |
|
||||
| 组件 | 19 | `modules/grades/components/**` | RSC + 客户端组件混合 |
|
||||
| 页面 | 18 | `app/(dashboard)/**/grades/**` | 四种角色页面 + loading/error |
|
||||
|
||||
### 1.2 主要数据流
|
||||
|
||||
```
|
||||
teacher/grades/page.tsx
|
||||
└─▶ requirePermission(GRADE_RECORD_READ)
|
||||
└─▶ grades/data-access.getGradeRecords(scope, currentUserId, limit, offset)
|
||||
└─▶ lib/scope-filter.buildScopeClassFilter → 行级过滤
|
||||
└─▶ db.query.gradeRecords (DB 层分页)
|
||||
└─▶ <GradeRecordList> + <GradeFilters> + <ExportButton>
|
||||
|
||||
teacher/grades/entry/page.tsx
|
||||
└─▶ requirePermission(GRADE_RECORD_MANAGE)
|
||||
└─▶ <BatchGradeEntryByExam>
|
||||
└─▶ exams/data-access.getExamsForGradeEntry
|
||||
└─▶ actions.batchCreateGradeRecordsByExamAction
|
||||
└─▶ 单事务写入 grade_records + grade_record_answers
|
||||
└─▶ 投影到 exam_submissions(status=graded) + submission_answers
|
||||
└─▶ 并行:diagnostic.updateMasteryFromExamScore + error-book.collectFromExamSubmission
|
||||
└─▶ notifyGradeEntered(批量通知学生 + 家长)
|
||||
|
||||
parent/grades/page.tsx
|
||||
└─▶ requirePermission(GRADE_RECORD_READ)
|
||||
└─▶ Promise.allSettled(为每个子女并行查询)
|
||||
└─▶ grades/data-access.getStudentGradeSummary
|
||||
└─▶ grades/data-access-analytics.getClassAverageTrend
|
||||
└─▶ <StudentGradeSummary> + <GradeTrendCard> + <ParentExportButton>
|
||||
```
|
||||
|
||||
### 1.3 架构图完整性核对
|
||||
|
||||
经核对 `004_architecture_impact_map.md` § 2.6 与 `005_architecture_data.json` `modules.grades`:
|
||||
|
||||
- **已记录且准确**:`exports.dataAccess`(15+ 函数)、`exports.actions`(17 个)、`exports.types`(7 类)、`exports.lib`(4 函数)、`exports.statsService`(8 纯函数)、`exports.export`(3 函数)、`exports.components`(WidgetBoundary/SchoolWideSummaryCard/ScoreCell)、`dependencies`(classes/school/users/exams/error-book/diagnostic/notifications/parent)、`knownIssues`(已修复 28 项)。
|
||||
- **架构图遗漏**:
|
||||
- `components/widget-boundary.tsx` 在文件清单中标注为 136 行,但未在 `exports.components` 中列出(仅在 P1-5 修复记录中提及)。
|
||||
- `lib/scope-check.ts` 的 `assertClassInScope` 函数未在 `exports.lib` 节点中列出(仅在 v4-P2-6 修复记录中提及)。
|
||||
- `parent/grades/page.tsx` 调用的 `getClassAverageTrend` data-access 函数未在架构图 `exports.dataAccess` 中记录。
|
||||
|
||||
### 1.4 标杆特性
|
||||
|
||||
该模块作为项目标杆,已落实的实践:
|
||||
- ✅ data-access 按职责拆分为 3 个文件(analytics/ranking/主)
|
||||
- ✅ Server Actions 全部 `requirePermission()` + Zod 校验
|
||||
- ✅ 统计逻辑抽取为 `stats-service.ts` 纯函数(8 个),可独立测试
|
||||
- ✅ 行级权限过滤 `buildScopeClassFilter(scope, currentUserId?)`
|
||||
- ✅ DB 层分页(`PaginatedGradeRecords`)而非内存分页
|
||||
- ✅ 事务原子性(`db.transaction`)
|
||||
- ✅ 跨模块全部通过 data-access 通信,无直查他模块表
|
||||
- ✅ 类型守卫(`isGradeType`/`isSemester`/`isDistributionTooltipPayload`)替代 `as` 断言
|
||||
- ✅ `handleActionError` 统一错误处理 + `safeActionCall` 包装器
|
||||
- ✅ a11y:4 图表 `role="img"` + `aria-label`、2 表格 `<caption>`、色盲友好双重编码
|
||||
- ✅ RSC 优先 + 客户端组件最小化
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 [P0] i18n 严重缺失 — 3 个 error.tsx 硬编码中文
|
||||
|
||||
| 文件 | 行号 | 硬编码文本 |
|
||||
|------|------|-----------|
|
||||
| [parent/grades/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/grades/error.tsx) | 16-20 | "子女成绩页面加载失败" / "抱歉,页面加载时发生了意外错误..." / "重试" |
|
||||
| [admin/school/grades/insights/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/error.tsx) | 17-21 | "成绩洞察页面加载失败" / "抱歉..." / "重试" |
|
||||
| [admin/school/grades/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/grades/error.tsx) | 12-16 | "页面加载失败" / "抱歉..." / "重试" |
|
||||
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n(next-intl),提取翻译键"。
|
||||
- **原因**:这三个 error.tsx 与 `teacher/grades/error.tsx`(已 i18n)同期创建,但遗漏未接入 `useTranslations`。
|
||||
- **直接后果**:非中文用户在页面出错时看到中文错误提示,国际化体验中断;与 teacher/grades/error.tsx 的良好实践形成明显不一致。
|
||||
|
||||
### 2.2 [P0] error.tsx 函数缺返回值类型标注
|
||||
|
||||
上述 3 个 error.tsx + `student/grades/error.tsx` + 所有 loading.tsx(共 7 个文件)的函数均未显式标注 `: JSX.Element` 返回值类型。
|
||||
|
||||
- **违反规则**:项目规则"TypeScript 严格模式:函数返回值必须显式标注,特别是 `Promise<T>`"。
|
||||
- **原因**:loading/error 文件通常由脚手架生成,未补类型标注。
|
||||
- **直接后果**:tsc 严格模式下可能漏检类型错误;代码可读性下降。
|
||||
|
||||
### 2.3 [P1] `actions.ts` 超行数上限(856 行)
|
||||
|
||||
[actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) 当前 856 行,超过 Server Actions 800 行建议上限 56 行。
|
||||
|
||||
- **违反规则**:项目规则"Server Actions / Data Access 模块:建议 ≤ 800 行"。
|
||||
- **原因**:模块职责持续扩张(草稿 CRUD、按试卷录入、撤销、批量删除、通知),所有逻辑集中在单文件。
|
||||
- **直接后果**:接近 1000 行硬上限,后续新增 Action 将突破硬限;可读性、code review 成本上升。
|
||||
|
||||
### 2.4 [P1] `data-access.ts` 接近行数上限(816 行)
|
||||
|
||||
[data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) 当前 816 行,超过 800 行建议上限 16 行。
|
||||
|
||||
- **违反规则**:同上。
|
||||
- **原因**:草稿 CRUD(`saveGradeDraft`/`getGradeDraft`/`deleteGradeDraft`)、按试卷录入(`batchCreateGradeRecordsByExam`)混入主文件。
|
||||
- **直接后果**:与 analytics/ranking 已拆分形成不一致;新增数据访问函数将很快突破硬限。
|
||||
|
||||
### 2.5 [P1] `actions.ts` 多处英文错误消息未 i18n
|
||||
|
||||
| 行号 | 硬编码英文 |
|
||||
|------|-----------|
|
||||
| 148 | "Invalid form data" |
|
||||
| 180 | "Grade record created" |
|
||||
| 253 | "Failed to delete grade record" |
|
||||
| 394-399 | "No records to undo" / "Cannot undo more than 500 records at once" |
|
||||
| 483-487 | "No records to delete" / "Cannot delete more than 500 records at once" |
|
||||
| 584-592 | "Can only view your own grades" / "Can only view your children's grades" |
|
||||
| 654-669 | "Can only view your own grade record" / "Can only view your children's grade records" |
|
||||
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **原因**:这些错误消息通过 `ActionState.error` 返回前端展示,早期未统一接入翻译键。
|
||||
- **直接后果**:非中文用户看到英文错误提示。
|
||||
|
||||
### 2.6 [P1] `lib/scope-check.ts` 返回消息未 i18n
|
||||
|
||||
[scope-check.ts](file:///e:/Desktop/CICD/src/modules/grades/lib/scope-check.ts) 第 24/29/31 行返回 "You can only access classes you teach" 等英文,这些消息会通过 Action 层直接返回用户。
|
||||
|
||||
- **违反规则**:同上。
|
||||
- **原因**:`assertClassInScope` 是同步函数,无法调用 `getTranslations`(异步),因此返回原始错误消息交由 Action 层包装。
|
||||
- **直接后果**:Action 层目前未对返回消息做翻译包装,直接透传给用户。
|
||||
|
||||
### 2.7 [P1] `actions-analytics.ts` scope 校验消息未 i18n
|
||||
|
||||
[actions-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/actions-analytics.ts) 第 176/183 行 "Can only view your own ranking trend" / "Can only view your children's ranking trend"。
|
||||
|
||||
- 同 2.5 原因与后果。
|
||||
|
||||
### 2.8 [P1] `batch-grade-entry.tsx` 超组件行数上限(604 行)
|
||||
|
||||
[batch-grade-entry.tsx](file:///e:/Desktop/CICD/src/modules/grades/components/batch-grade-entry.tsx) 604 行,超过组件 500 行建议上限 104 行(复杂表单可放宽至 800,但仍建议拆分)。
|
||||
|
||||
- **违反规则**:项目规则"React 组件:建议 ≤ 500 行(复杂表单/大型表格可放宽至 800)"。
|
||||
- **原因**:Excel 式表格 + 试卷/班级选择器 + 统计栏 + 撤销机制 + AlertDialog 全部集中在单文件。
|
||||
- **直接后果**:可读性下降;子组件无法独立复用与测试;状态管理复杂度高,易引入 bug。
|
||||
|
||||
### 2.9 [P1] `batch-grade-entry.tsx` 第 341 行 `as` 断言无类型守卫
|
||||
|
||||
```ts
|
||||
JSON.parse(raw) as { ids: string[]; timestamp: number }
|
||||
```
|
||||
|
||||
- **违反规则**:项目规则"禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)"。
|
||||
- **原因**:sessionStorage 撤销数据反序列化未做类型守卫。
|
||||
- **直接后果**:数据被篡改时可能运行时报错;类型安全缺失。
|
||||
|
||||
### 2.10 [P1] `parent/grades/page.tsx` 第 63 行 `as` 断言
|
||||
|
||||
```ts
|
||||
summary as NonNullable<typeof r.value.summary>
|
||||
```
|
||||
|
||||
- **违反规则**:同 2.9。
|
||||
- **原因**:虽有前置 `filter(r => r.value.summary)` 保证,但严格违反"禁止 as 断言"规则。
|
||||
- **直接后果**:类型系统对运行时假设依赖,filter 逻辑变更时可能失效。
|
||||
|
||||
### 2.11 [P2] `student-grade-summary.tsx` / `grade-record-list.tsx` 硬编码 "S" 前缀
|
||||
|
||||
两文件均使用 `<TableCell>S{r.semester}</TableCell>` 硬编码 "S1"/"S2" 前缀。
|
||||
|
||||
- **违反规则**:i18n 规则。
|
||||
- **原因**:学期显示格式未抽象为翻译键。
|
||||
- **直接后果**:非英文/中文语境下学期标签不符合本地化习惯。
|
||||
|
||||
### 2.12 [P2] 多个组件函数缺返回值类型标注
|
||||
|
||||
| 文件 | 函数 |
|
||||
|------|------|
|
||||
| `grade-filters.tsx` | `GradeFilters` |
|
||||
| `grade-query-filters.tsx` | `GradeQueryFilters` |
|
||||
| `export-button.tsx` | `handleExport` |
|
||||
| `grade-record-form.tsx` | `handleSubmit` |
|
||||
| 所有 loading.tsx(7 个) | `Loading` |
|
||||
|
||||
- **违反规则**:函数返回值必须显式标注。
|
||||
- **原因**:脚手架/早期代码遗漏。
|
||||
- **直接后果**:类型推导依赖,严格模式潜在漏检。
|
||||
|
||||
### 2.13 [P2] `data-access.ts` `updateGradeRecord` 类型安全性较弱
|
||||
|
||||
第 407 行使用 `Record<string, unknown>` 累积更新字段。
|
||||
|
||||
- **违反规则**:TypeScript 严格模式精神(虽未直接违反禁止 any)。
|
||||
- **原因**:Drizzle 动态更新字段累积时类型推导困难。
|
||||
- **直接后果**:可能写入非法字段名;类型安全缺失。
|
||||
|
||||
### 2.14 [P2] `getStudentGradeSummary` 内部调用 `getClassRanking` 形成 data-access 层内部耦合
|
||||
|
||||
[data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) 第 564 行 `getStudentGradeSummary` 内部调用 `getClassRanking`,形成 data-access 层内部依赖。
|
||||
|
||||
- **原因**:学生成绩摘要需要排名信息,直接复用排名查询。
|
||||
- **直接后果**:语义上 `getStudentGradeSummary` 不应依赖 `getClassRanking`;两者缓存参数不完全一致,可能导致重复查询;测试时需 mock 排名逻辑。
|
||||
|
||||
### 2.15 [P2] 组件复用抽象不足
|
||||
|
||||
| 现状 | 建议 |
|
||||
|------|------|
|
||||
| `GradeFilters`(student,nuqs) 与 `GradeQueryFilters`(teacher,URLSearchParams) 两个独立过滤器 | 抽象为统一组件 + props 配置 |
|
||||
| `ExportButton`(teacher) 与 `ParentExportButton`(parent) | 可统一为带 `variant` prop 的单一组件 |
|
||||
| `AnalyticsFilters`(teacher RSC) 与 `GradeInsightsFilters`(school ChipNav) | 可考虑统一 |
|
||||
|
||||
- **违反规则**:项目规则"识别四个角色共用的 UI 块和业务逻辑块,抽象为泛型组件和 hooks"。
|
||||
- **原因**:各角色过滤器实现差异较大(nuqs vs URLSearchParams vs ChipNav),未做抽象。
|
||||
- **直接后果**:新增角色需重写过滤器;维护成本上升。
|
||||
|
||||
### 2.16 [P2] 缺少 Error Boundary 包裹的独立数据区块
|
||||
|
||||
`teacher/grades/page.tsx` 列表与统计卡片未用 `WidgetBoundary` 包裹(仅 analytics 页面已包裹)。
|
||||
|
||||
- **违反规则**:项目规则"每个独立的数据区块必须用 React Error Boundary 包裹"。
|
||||
- **原因**:主页面早期未应用 WidgetBoundary。
|
||||
- **直接后果**:单个数据区块失败会导致整个页面崩溃。
|
||||
|
||||
### 2.17 [P2] 撤销机制仅依赖 sessionStorage(5 分钟)
|
||||
|
||||
`batch-grade-entry.tsx` 撤销令牌存于 sessionStorage,刷新或跨设备后丢失;`undoBatchCreateGradeRecordsAction` 无时间窗口限制,理论上可撤销任意时间自己录入的记录。
|
||||
|
||||
- **违反规则**:项目规则"错误与边界处理:明确处理空数据、无权限、网络异常等边界状态"(隐含)。
|
||||
- **原因**:撤销机制为 v4 新增,未考虑持久化与时效。
|
||||
- **直接后果**:误删风险;跨设备无法撤销。
|
||||
|
||||
### 2.18 [P2] 通知串行写入
|
||||
|
||||
`notifyGradeEntered` 对每个学生/家长串行调用 `createNotification`(actions.ts 第 88-117 行)。
|
||||
|
||||
- **违反规则**:性能最佳实践。
|
||||
- **原因**:通知 data-access 未提供批量接口。
|
||||
- **直接后果**:批量录入 30+ 学生时通知耗时显著。
|
||||
|
||||
### 2.19 [P2] 图表 tooltip a11y 不足
|
||||
|
||||
图表组件有 `role="img"` + `aria-label`,但 tooltip(recharts tooltip)的 a11y 支持不足,屏幕阅读器无法读取悬停数据。
|
||||
|
||||
### 2.20 [P2] `lib/type-guards.ts` 第 8/12 行 `as` 断言
|
||||
|
||||
```ts
|
||||
(GRADE_TYPES as readonly string[]).includes(v)
|
||||
```
|
||||
|
||||
- **违反规则**:禁止 as 断言(虽属类型收窄,但严格违规)。
|
||||
- **改进**:改用 `Array.from(GRADE_TYPES).includes(v)` 或 `(GRADE_TYPES as readonly unknown[]).includes(v as unknown)` 后做类型守卫。
|
||||
|
||||
### 2.21 [P3] 架构图遗漏
|
||||
|
||||
- `components/widget-boundary.tsx` 未在 `exports.components` 节点列出
|
||||
- `lib/scope-check.ts` 的 `assertClassInScope` 未在 `exports.lib` 节点列出
|
||||
- `getClassAverageTrend` data-access 函数未记录
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
对标 PowerSchool / Gradebook、Google Classroom、Canvas LMS、智学网、校宝在线等 K12 成绩管理产品,本模块差距如下:
|
||||
|
||||
### 3.1 缺失:成绩报告卡(Report Card)PDF 导出
|
||||
|
||||
- **行业实践**:PowerSchool / Canvas 支持生成单生/班级 PDF 成绩报告卡,含学期汇总、排名、教师评语、签名区。
|
||||
- **本模块现状**:仅支持 Excel 导出(`exportGradeRecordsToExcel`),无 PDF。
|
||||
- **影响**:家长会、学期末场景需手动排版打印,效率低。
|
||||
|
||||
### 3.2 缺失:成绩变化智能预警
|
||||
|
||||
- **行业实践**:智学网提供"成绩大幅波动预警"(分数下降超过 10 分自动通知班主任 + 家长)。
|
||||
- **本模块现状**:无预警机制,仅静态统计。
|
||||
- **影响**:教师无法及时发现学习下滑学生,错过干预窗口。
|
||||
|
||||
### 3.3 缺失:知识点维度的成绩诊断
|
||||
|
||||
- **行业实践**:Canvas LMS 的 MasteryGradebook 按知识点(competency)展示掌握度,而非仅按考试/科目。
|
||||
- **本模块现状**:成绩按科目/考试维度,虽 `grade_record_answers` 已存储每题得分,但未提供知识点聚合视图(依赖 diagnostic 模块但未集成到 grades 页面)。
|
||||
- **影响**:教师无法在成绩页直接看到班级在哪个知识点薄弱。
|
||||
|
||||
### 3.4 缺失:学生纵向成长档案(Individual Growth Report)
|
||||
|
||||
- **行业实践**:PowerSchool 提供单生多年纵向趋势图,含跨学期/跨年级对比。
|
||||
- **本模块现状**:`GradeTrendCard` 仅展示单学期/单科目趋势,无跨年级纵向档案。
|
||||
- **影响**:家长无法全面了解学生长期成长轨迹。
|
||||
|
||||
### 3.5 缺失:班级横向对比的统计显著性标注
|
||||
|
||||
- **行业实践**:专业 BI 工具(如 Tableau 教育版)在班级对比时标注 p 值、效应量(Cohen's d)。
|
||||
- **本模块现状**:`ClassComparisonChart` 有"显著性分析区域"(v3-P3-5 新增),但仅基于极差经验规则,未使用统计检验。
|
||||
- **影响**:教师可能过度解读小样本差异。
|
||||
|
||||
### 3.6 缺失:成绩录入的草稿协同
|
||||
|
||||
- **行业实践**:Google Classroom 支持多位教师协同录入,草稿云端同步,锁定机制防止冲突。
|
||||
- **本模块现状**:草稿仅单用户单设备(`grade_drafts` 表按 userId 唯一),无协同。
|
||||
- **影响**:多教师任教同一班级时无法分工录入。
|
||||
|
||||
### 3.7 缺失:成绩申诉与复核流程
|
||||
|
||||
- **行业实践**:Canvas 支持学生对成绩提出申诉,教师复核后修改并留痕。
|
||||
- **本模块现状**:仅 `updateGradeRecord`,无申诉状态机与审计轨迹。
|
||||
- **影响**:成绩修改无留痕,合规性不足。
|
||||
|
||||
### 3.8 缺失:移动端适配深度优化
|
||||
|
||||
- **行业实践**:校宝在线移动端针对家长提供"成绩单卡片"简化视图,大表格转为卡片堆叠。
|
||||
- **本模块现状**:`grade-record-list.tsx` 有 `overflow-x-auto`,但未做卡片堆叠适配;图表在移动端可能过小。
|
||||
- **影响**:家长移动端查看体验一般。
|
||||
|
||||
### 3.9 缺失:成绩分布的隐私保护
|
||||
|
||||
- **行业实践**:GDPR/FERPA 合规要求学生视角的分布图应隐藏其他学生身份,仅显示相对位置。
|
||||
- **本模块现状**:学生视角的 `GradeDistributionChart` 显示全班分布,虽未暴露个人身份,但需明确"你在分布中的位置"标注。
|
||||
- **影响**:隐私合规风险(部分国家/地区)。
|
||||
|
||||
### 3.10 缺失:批量导入 Excel 成绩
|
||||
|
||||
- **行业实践**:智学网支持下载模板 → 填写 → 上传 Excel 批量导入。
|
||||
- **本模块现状**:`batch-grade-entry.tsx` 支持下载 CSV 模板 + 粘贴录入,但无完整 Excel 上传解析。
|
||||
- **影响**:教师需手动粘贴,大批量场景效率低。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 立即修复(影响用户体验与合规)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P0-1 | 3 个 error.tsx 硬编码中文(2.1) | 改为 `useTranslations("grades")` + 新增 `error.*` 翻译键(zh-CN/en 同步) |
|
||||
| P0-2 | 7 个 loading/error.tsx 函数缺返回值类型(2.2) | 补全 `: JSX.Element` 返回值类型标注 |
|
||||
|
||||
### P1 — 本周修复(架构规范与 i18n)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P1-1 | `actions.ts` 超 800 行(2.3) | 拆分草稿相关 3 个 Action 到 `actions-draft.ts`;拆分通知逻辑到 `lib/notify.ts` |
|
||||
| P1-2 | `data-access.ts` 超 800 行(2.4) | 拆分草稿 CRUD 到 `data-access-drafts.ts`;拆分按试卷录入到 `data-access-exam-entry.ts` |
|
||||
| P1-3 | `actions.ts` 多处英文错误消息未 i18n(2.5) | 新增 `action.error.*` 翻译键,所有错误消息改用 `t("action.error.*")` |
|
||||
| P1-4 | `lib/scope-check.ts` 返回消息未 i18n(2.6) | 改为返回错误码枚举,由 Action 层翻译;或返回 `null` + Action 层自行抛错 |
|
||||
| P1-5 | `actions-analytics.ts` scope 消息未 i18n(2.7) | 同 P1-3 |
|
||||
| P1-6 | `batch-grade-entry.tsx` 超 500 行(2.8) | 拆分为 `BatchGradeEntryTable` + `BatchGradeEntryStats` + `BatchGradeEntryDialogs` 子组件 |
|
||||
| P1-7 | `batch-grade-entry.tsx` 第 341 行 as 断言(2.9) | 新增 `isUndoData` 类型守卫函数 |
|
||||
| P1-8 | `parent/grades/page.tsx` 第 63 行 as 断言(2.10) | 改用类型守卫 `isSummaryFulfilled` 或非空检查 |
|
||||
| P1-9 | `teacher/grades/page.tsx` 列表与统计未 WidgetBoundary 包裹(2.16) | 用 `<WidgetBoundary>` 包裹独立区块 |
|
||||
|
||||
### P2 — 中期改进(质量与一致性)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P2-1 | "S" 前缀硬编码(2.11) | 新增 `semester.s1`/`semester.s2` 翻译键 |
|
||||
| P2-2 | 组件函数缺返回值类型(2.12) | 补全 `: JSX.Element` / `: Promise<void>` |
|
||||
| P2-3 | `updateGradeRecord` 类型弱(2.13) | 改用 `Partial<typeof gradeRecords.$inferInsert>` |
|
||||
| P2-4 | `getStudentGradeSummary` 内部耦合 `getClassRanking`(2.14) | 将排名计算抽取到 action 层或 stats-service |
|
||||
| P2-5 | 过滤器组件未抽象(2.15) | 抽象 `GradeFilterBar` 统一组件 + props 配置 |
|
||||
| P2-6 | 撤销机制 sessionStorage 依赖(2.17) | 服务端存储撤销令牌 + 24 小时时间窗口 |
|
||||
| P2-7 | 通知串行写入(2.18) | 新增 `notifications/data-access.bulkCreateNotification` 批量接口 |
|
||||
| P2-8 | 图表 tooltip a11y(2.19) | 添加 `aria-describedby` + 旁路数据表(sr-only) |
|
||||
| P2-9 | `lib/type-guards.ts` as 断言(2.20) | 改用 `Array.from(GRADE_TYPES).includes(v)` |
|
||||
|
||||
### P3 — 长期改进(行业对齐与企业级)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P3-1 | 缺 PDF 成绩报告卡(3.1) | 新增 `lib/report-card.ts`,基于 `@react-pdf/renderer` 生成 PDF |
|
||||
| P3-2 | 缺成绩变化预警(3.2) | 新增 `stats-service.detectScoreAnomaly` 纯函数 + 通知触发 |
|
||||
| P3-3 | 缺知识点维度诊断集成(3.3) | 在 analytics 页面集成 diagnostic 模块的知识点掌握度视图 |
|
||||
| P3-4 | 缺学生纵向档案(3.4) | 新增 `getStudentGrowthArchive` data-access + `GrowthArchiveChart` 组件 |
|
||||
| P3-5 | 缺统计显著性标注(3.5) | 引入 `simple-statistics` 库计算 p 值 + Cohen's d |
|
||||
| P3-6 | ✅ 缺协同录入(3.6) | `grade_drafts` 表新增 `lockedBy`/`lockedAt`/`lockToken` 字段 + classSubjectLockIdx 索引,支持锁机制(data-access-drafts.ts 5 个锁函数 + actions-lock.ts 4 个 Server Action + use-draft-lock Hook + DraftLockBanner 组件 + i18n) |
|
||||
| P3-7 | ✅ 缺申诉流程(3.7) | 新增 `grade_appeals` 表(状态机枚举+4 个索引)+ data-access-appeals.ts(7 个函数)+ actions-appeal.ts(5 个 Server Action)+ schema.ts Zod 校验 + i18n |
|
||||
| P3-8 | 缺移动端卡片堆叠(3.8) | 新增 `GradeRecordCardList` 移动端组件 |
|
||||
| P3-9 | 缺隐私保护位置标注(3.9) | 学生视角分布图添加"你的位置"标注 |
|
||||
| P3-10 | 缺 Excel 上传批量导入(3.10) | 集成 `xlsx` 库,新增 `importGradesFromExcel` Action |
|
||||
| P3-11 | 单元测试覆盖(2.14 关联) | 为 stats-service.ts 纯函数 + lib/scope-check.ts 补充 vitest 测试 |
|
||||
|
||||
### 架构图同步
|
||||
|
||||
| # | 改进内容 | 架构图更新 |
|
||||
|---|---------|-----------|
|
||||
| A-1 | P1-1 拆分 actions.ts | 004 文件清单 + 005 exports.actions 节点 |
|
||||
| A-2 | P1-2 拆分 data-access.ts | 004 文件清单 + 005 exports.dataAccess 节点 |
|
||||
| A-3 | P1-6 拆分 batch-grade-entry | 004 文件清单 + 005 exports.components 节点 |
|
||||
| A-4 | 补全遗漏 | 004/005 补充 widget-boundary、scope-check.assertClassInScope、getClassAverageTrend |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 本次审计发现的架构图遗漏(需补充)
|
||||
|
||||
1. **`components/widget-boundary.tsx`** — 在 004 § 2.6 文件清单中标注为 136 行,但未在 `exports.components` 节点中列出。需在 005 `modules.grades.exports.components` 数组中新增条目。
|
||||
2. **`lib/scope-check.ts` 的 `assertClassInScope`** — 仅在 v4-P2-6 修复记录中提及,未在 `exports.lib` 节点列出。需补充。
|
||||
3. **`getClassAverageTrend` data-access 函数** — `parent/grades/page.tsx` 调用但架构图 `exports.dataAccess` 未记录。需确认其在 `data-access-analytics.ts` 中的位置并补充。
|
||||
|
||||
### 5.2 重构后需同步的节点
|
||||
|
||||
- 若执行 P1-1(拆分 actions.ts):004 文件清单更新为 `actions.ts`(barrel)+ `actions-draft.ts` + `actions-exam-entry.ts`;005 `exports.actions` 按文件分组。
|
||||
- 若执行 P1-2(拆分 data-access.ts):004 文件清单更新为 `data-access.ts` + `data-access-drafts.ts` + `data-access-exam-entry.ts`;005 `exports.dataAccess` 按文件分组。
|
||||
- 若执行 P1-6(拆分 batch-grade-entry):004 文件清单新增 3 个子组件;005 `exports.components` 补充。
|
||||
- P0/P1 i18n 修复无需更新架构图(翻译键不影响模块导出结构)。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施计划
|
||||
|
||||
> 本节为审计文档的实施清单,按优先级执行,每步完成后运行 `npm run lint && npx tsc --noEmit` 验证零错误。
|
||||
|
||||
### 阶段 1:P0 立即修复(本次会话完成)
|
||||
|
||||
- [ ] P0-1: 3 个 error.tsx i18n(parent/grades、admin/school/grades、admin/school/grades/insights)
|
||||
- [ ] P0-2: 7 个 loading/error.tsx 函数补全返回值类型
|
||||
|
||||
### 阶段 2:P1 本周修复(本次会话完成)
|
||||
|
||||
- [ ] P1-1: 拆分 `actions.ts` → `actions-draft.ts` + 通知抽取到 `lib/notify.ts`
|
||||
- [ ] P1-2: 拆分 `data-access.ts` → `data-access-drafts.ts` + `data-access-exam-entry.ts`
|
||||
- [ ] P1-3: `actions.ts` 错误消息 i18n(新增 `action.error.*` 翻译键)
|
||||
- [ ] P1-4: `lib/scope-check.ts` 改为返回错误码枚举
|
||||
- [ ] P1-5: `actions-analytics.ts` scope 消息 i18n
|
||||
- [ ] P1-6: 拆分 `batch-grade-entry.tsx` 为子组件
|
||||
- [ ] P1-7: `batch-grade-entry.tsx` 第 341 行 as 断言 → 类型守卫
|
||||
- [ ] P1-8: `parent/grades/page.tsx` 第 63 行 as 断言 → 类型守卫
|
||||
- [ ] P1-9: `teacher/grades/page.tsx` WidgetBoundary 包裹
|
||||
|
||||
### 阶段 3:P2 中期改进(本次会话完成)
|
||||
|
||||
- [ ] P2-1: "S" 前缀 i18n
|
||||
- [ ] P2-2: 组件函数返回值类型补全
|
||||
- [ ] P2-3: `updateGradeRecord` 类型强化
|
||||
- [ ] P2-4: 解耦 `getStudentGradeSummary` 与 `getClassRanking`
|
||||
- [ ] P2-5: 过滤器组件抽象(评估后决定是否实施)
|
||||
- [ ] P2-6: 撤销机制服务端持久化(评估后决定)
|
||||
- [ ] P2-7: 通知批量接口(依赖 notifications 模块改造,标注为后续)
|
||||
- [ ] P2-8: 图表 tooltip a11y
|
||||
- [ ] P2-9: `lib/type-guards.ts` as 断言修复
|
||||
|
||||
### 阶段 4:P3 长期改进(记录待排期,不在本次会话实施)
|
||||
|
||||
- [ ] P3-1 ~ P3-10:行业差距对齐(需产品评估与设计)
|
||||
- [ ] P3-11:单元测试覆盖
|
||||
|
||||
### 阶段 5:架构图同步(本次会话完成)
|
||||
|
||||
- [ ] A-1 ~ A-4:补充 004/005 遗漏节点 + 重构后节点更新
|
||||
348
docs/architecture/audit/homework-audit-report.md
Normal file
348
docs/architecture/audit/homework-audit-report.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Homework(作业)模块审计报告
|
||||
|
||||
> 审计时间:2026-06-25
|
||||
> 审计范围:`src/modules/homework/**` 全部文件 + `src/app/(dashboard)/**/homework/**` 与 `src/app/(dashboard)/student/learning/assignments/**` 路由
|
||||
> 审计基线:项目规则 `.trae/rules/project_rules.md`、架构影响地图 `docs/architecture/004_architecture_impact_map.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布与行数
|
||||
|
||||
该模块共 32 个文件,分布于 4 个目录:
|
||||
|
||||
| 目录 | 文件数 | 总行数 | 关键文件 |
|
||||
|------|--------|--------|----------|
|
||||
| `homework/` | 11 | 3234 | `actions.ts`(597) / `data-access.ts`(629) / `data-access-write.ts`(455) / `data-access-student.ts`(293) / `data-access-classes.ts`(285) / `stats-service.ts`(414) / `types.ts`(257) / `schema.ts`(56) |
|
||||
| `homework/components/` | 17 | 3686 | `homework-take-view.tsx`(520) / `homework-grading-view.tsx`(499) / `question-renderer.tsx`(339) / `homework-scan-grading-view.tsx`(262) / `scan-uploader.tsx`(254) / `homework-submission-result.tsx`(224) / `homework-assignment-form.tsx`(219) / `student-homework-review-view.tsx`(206) / `excellent-submissions.tsx`(196) / `scan-image-viewer.tsx`(194) / `homework-batch-grading-view.tsx`(165) / `homework-assignment-question-error-detail-panel.tsx`(132) |
|
||||
| `homework/hooks/` | 2 | 288 | `use-debounced-auto-save.ts`(182) / `use-exam-countdown.ts`(106) |
|
||||
| `homework/lib/` | 2 | 720 | `question-content-utils.ts`(305) / `question-content-utils.test.ts`(415) |
|
||||
|
||||
### 1.2 主要数据流
|
||||
|
||||
- **创建作业**:`teacher/homework/assignments/create/page.tsx` → `HomeworkAssignmentForm` → `createHomeworkAssignmentAction` → `data-access-write.createHomeworkAssignment`(交叉调用 `classes.data-access` / `exams.data-access`)
|
||||
- **学生作答**:`student/learning/assignments/[assignmentId]/page.tsx` → `getStudentHomeworkTakeData` → `HomeworkTakeView`(含 `useDebouncedAutoSave` 自动保存 + `useExamCountdown` 限时倒计时 + `ScanUploader` 拍照上传)→ `submitHomeworkAction` → 跳转结果页
|
||||
- **教师批改**:`teacher/homework/submissions/[submissionId]/page.tsx` → `HomeworkGradingView`(含 AI 批改助手)/ `scan-grading/page.tsx` → `HomeworkScanGradingView`(阅卷式批改)
|
||||
- **批改后处理**:`actions.ts::runPostGradingHooks` 并行执行错题采集(`error-book.data-access-collection`)+ 掌握度更新(`diagnostic.data-access`)
|
||||
- **优秀作业展示**:`excellent-submissions.tsx` 已使用 `SectionErrorBoundary + Suspense + Skeleton` 模式
|
||||
|
||||
### 1.3 架构影响地图覆盖情况
|
||||
|
||||
`docs/architecture/004_architecture_impact_map.md` 的 `## 2.3 homework(作业模块)` 章节对该模块的导出函数、依赖关系、已知问题、文件清单均有较完整记录。但存在以下不一致:
|
||||
|
||||
1. **行数信息滞后**:地图记录 `data-access.ts` 598 行、`actions.ts` 239 行、`schema.ts` 29 行、`types.ts` 186 行;实际分别为 629 / 597 / 56 / 257 行。
|
||||
2. **新增组件未记录**:`homework-assignment-question-error-detail-panel.tsx`、`homework-assignment-question-error-overview-card.tsx`、`homework-assignment-exam-error-explorer-lazy.tsx`、`homework-assignment-exam-preview-pane.tsx`、`homework-assignment-exam-error-explorer.tsx` 在文件清单中部分缺失行数。
|
||||
3. **测试文件未记录**:`lib/question-content-utils.test.ts`(415 行)未在文件清单中体现。
|
||||
4. **`scan-uploader.tsx` 中 `ScanImage` 类型已记录在 Types 段,但 `actions.ts` 中内联定义的 `ScanAttachment` 接口未在 `types.ts` 中导出**——这与地图描述"Types:`ScanAttachment`"不符,实际位置在 `actions.ts:443`。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 P0 - 违反硬性规则的问题
|
||||
|
||||
#### 问题 2.1.1 - 多个路由缺失 `loading.tsx` 与 `error.tsx`
|
||||
|
||||
**位置**:以下路由缺失:
|
||||
|
||||
| 缺失文件 | 同级已有 |
|
||||
|----------|----------|
|
||||
| `teacher/homework/loading.tsx` | `teacher/homework/page.tsx` |
|
||||
| `teacher/homework/error.tsx` | `teacher/homework/page.tsx` |
|
||||
| `teacher/homework/assignments/loading.tsx` | `teacher/homework/assignments/page.tsx` |
|
||||
| `teacher/homework/assignments/error.tsx` | `teacher/homework/assignments/page.tsx` |
|
||||
| `teacher/homework/submissions/loading.tsx` | `teacher/homework/submissions/page.tsx` |
|
||||
| `teacher/homework/submissions/error.tsx` | `teacher/homework/submissions/page.tsx` |
|
||||
| `teacher/homework/submissions/[submissionId]/loading.tsx` | `teacher/homework/submissions/[submissionId]/page.tsx` |
|
||||
| `teacher/homework/submissions/[submissionId]/error.tsx` | `teacher/homework/submissions/[submissionId]/page.tsx` |
|
||||
| `teacher/homework/submissions/[submissionId]/scan-grading/loading.tsx` | `teacher/homework/submissions/[submissionId]/scan-grading/page.tsx` |
|
||||
| `teacher/homework/submissions/[submissionId]/scan-grading/error.tsx` | `teacher/homework/submissions/[submissionId]/scan-grading/page.tsx` |
|
||||
| `student/learning/assignments/error.tsx` | `student/learning/assignments/page.tsx` |
|
||||
| `student/learning/assignments/[assignmentId]/result/loading.tsx` | `student/learning/assignments/[assignmentId]/result/page.tsx` |
|
||||
| `student/learning/assignments/[assignmentId]/result/error.tsx` | `student/learning/assignments/[assignmentId]/result/page.tsx` |
|
||||
|
||||
**违反规则**:项目记忆 `Hard Constraints` 中明确"所有学生路由必须包含 `loading.tsx` 和 `error.tsx` 用于错误边界";项目规则也强调错误边界与 Suspense 骨架屏是企业级硬性要求。
|
||||
|
||||
**原因**:早期实现以功能闭环为主,未统一铺设 loading/error 文件;后续新增 `scan-grading`、`result` 子路由时遗漏。
|
||||
|
||||
**后果**:访问上述路由时如发生异常会冒泡到顶层 `app/error.tsx`,破坏整页布局;首屏无骨架屏导致白屏感知差,违反"异步数据使用 React Suspense + 骨架屏"原则。
|
||||
|
||||
#### 问题 2.1.2 - `homework-take-view.tsx` 超过 500 行组件规范
|
||||
|
||||
**位置**:`src/modules/homework/components/homework-take-view.tsx`(520 行)
|
||||
|
||||
**违反规则**:项目规则"React 组件:建议 ≤ 500 行(复杂表单/大型表格可放宽至 800 行)"。该组件并非大型表格,应控制在 500 行内。
|
||||
|
||||
**原因**:学生作答页同时承担「未开始引导 + 倒计时 + 题目作答 + 扫描图上传 + 提交确认对话框 + 离线缓存恢复」6 块逻辑。
|
||||
|
||||
**后果**:阅读和维护成本上升,单测难度大,且 `useState` 集中在 1 个组件触发不必要的整体重渲染。
|
||||
|
||||
#### 问题 2.1.3 - `schema.ts` 错误消息硬编码中文
|
||||
|
||||
**位置**:`src/modules/homework/schema.ts:31-46`
|
||||
|
||||
```typescript
|
||||
message: "截止时间必须晚于可用时间"
|
||||
message: "迟交截止时间必须晚于正常截止时间"
|
||||
message: "允许迟交时必须设置迟交截止时间"
|
||||
message: "Invalid date format"
|
||||
message: "Title is required for quick assignments"
|
||||
```
|
||||
|
||||
**违反规则**:项目记忆 `Hard Constraints` 中"所有用户可见文本必须适配 i18n"。Zod 校验错误最终会通过 `parsed.error.flatten().fieldErrors` 返回到 UI 展示。
|
||||
|
||||
**原因**:Zod schema 在模块加载时即构造,无法直接调用 `next-intl` 的 Hook;开发者选择硬编码中文。
|
||||
|
||||
**后果**:英文环境用户看到的错误消息为中文;多语言场景下的体验割裂。
|
||||
|
||||
#### 问题 2.1.4 - `ScanAttachment` 类型未沉淀到 `types.ts`
|
||||
|
||||
**位置**:`src/modules/homework/actions.ts:443-450`(inline 定义 + export)
|
||||
|
||||
**违反规则**:项目规则"`modules/[module]/types.ts` 是类型定义的统一出口"。
|
||||
|
||||
**原因**:开发 `getScansAction` 时图省事就近定义。
|
||||
|
||||
**后果**:架构图描述与实际不符;其他模块若需复用 `ScanAttachment` 类型将被迫从 `actions.ts`(带 `"use server"`)导入,引发误用风险。
|
||||
|
||||
### 2.2 P1 - 应优化但不阻断
|
||||
|
||||
#### 问题 2.2.1 - `homework-grading-view.tsx` 残留本地 wrapper 函数
|
||||
|
||||
**位置**:`src/modules/homework/components/homework-grading-view.tsx:508-540`
|
||||
|
||||
```typescript
|
||||
// Delegate to shared pure functions in lib/question-content-utils
|
||||
// (kept here only as thin wrappers to preserve existing call sites)
|
||||
const isAutoGradable = (ans) => isAutoGradableUtil({...})
|
||||
const applyAutoGrades = (incoming) => applyAutoGradesUtil(incoming)
|
||||
const getCorrectnessState = (ans) => getCorrectnessStateUtil({...})
|
||||
const extractQuestionText = (content) => ...
|
||||
```
|
||||
|
||||
**违反规则**:项目规则"避免 backwards-compatibility hacks"。
|
||||
|
||||
**原因**:从 lib 抽离纯函数后,为减少改动保留了过渡 wrapper。
|
||||
|
||||
**后果**:调用方多一层无意义的函数调用;阅读时需在两个文件间跳转才能确认实际行为。
|
||||
|
||||
#### 问题 2.2.2 - `data-access.ts` 末尾 re-export 子模块函数
|
||||
|
||||
**位置**:`src/modules/homework/data-access.ts:578-580`
|
||||
|
||||
```typescript
|
||||
// Re-exports for backward compatibility — split into focused modules (P2-4)
|
||||
export { getHomeworkAssignmentsByExamId, getGradedSubmissionsByExamId } from "./data-access-exam-cross"
|
||||
export { getStudentSubmissionResult, getStudentExamResults, getStudentHomeworkAssignments, getStudentHomeworkTakeData } from "./data-access-student"
|
||||
```
|
||||
|
||||
**违反规则**:项目规则"避免 backwards-compatibility hacks"。同时架构图明确说"`stats-service.ts` re-export 以保持向后兼容"——同样的 pattern 出现在 `data-access.ts`。
|
||||
|
||||
**原因**:拆分大文件时为不破坏既有 import 路径而保留 re-export。
|
||||
|
||||
**后果**:调用方难以判断函数真正实现位置;IDE 跳转可能落在 `data-access.ts` 而非真实实现文件。
|
||||
|
||||
#### 问题 2.2.3 - 部分组件未使用 `SectionErrorBoundary` + `Suspense`
|
||||
|
||||
**位置**:
|
||||
- `teacher/homework/assignments/[id]/page.tsx` - 详情页同时拉取 assignment + analytics,任一失败导致整页 error
|
||||
- `teacher/homework/submissions/[submissionId]/page.tsx` - 批改页同时拉取 submission details + scans
|
||||
|
||||
**违反规则**:项目规则"每个独立的数据区块必须用 React Error Boundary 包裹"。
|
||||
|
||||
**原因**:仅在 `excellent-submissions.tsx` 落地了 SectionErrorBoundary 模式,未推广。
|
||||
|
||||
**后果**:局部数据加载失败导致整页不可用,不符合"渐进式降级"。
|
||||
|
||||
### 2.3 P2 - 长期改进
|
||||
|
||||
#### 问题 2.3.1 - 角色差异硬编码在路由层,未配置驱动
|
||||
|
||||
**位置**:`teacher/homework/**`、`student/learning/assignments/**`、家长侧通过 `parent/data-access` 调用
|
||||
|
||||
**说明**:4 个角色(admin/teacher/parent/student)各自走独立路由,UI 与逻辑无法跨角色复用。新增角色需复制整套路由。
|
||||
|
||||
**违反原则**:项目规则"最大化复用 - 识别四个角色共用的 UI 块和业务逻辑块"。
|
||||
|
||||
#### 问题 2.3.2 - 关键操作埋点不全
|
||||
|
||||
**位置**:仅 `getExcellentSubmissionsAction` 调用了 `trackExamEvent("homework.excellent_viewed")`
|
||||
|
||||
**缺失**:作业创建、提交、批改、扫描图上传/删除均未埋点。
|
||||
|
||||
**违反原则**:项目规则"监控:方案中预留关键操作埋点接口"。
|
||||
|
||||
#### 问题 2.3.3 - `lib/question-content-utils.ts` 与组件耦合的少量工具未抽取
|
||||
|
||||
**位置**:`homework-grading-view.tsx::extractQuestionText` 仍内联在组件内(行 525+),未沉淀到 lib。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
参考 K12 教育系统主流产品(智学网、猿题库、作业帮、ClassDojo、Google Classroom、PowerSchool):
|
||||
|
||||
### 3.1 学生侧
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 差距 |
|
||||
|------|--------------|----------|------|
|
||||
| **作答进度恢复** | 断网/掉线后 100% 恢复草稿 + 显示恢复提示 | localStorage 离线缓存已实现 + beforeunload 警告 | ✅ 基本对齐 |
|
||||
| **限时考试倒计时** | 顶部固定 + 全屏倒计时 + 剩 5 分钟变色提醒 | `useExamCountdown` 实现紧急状态高亮 | ✅ 对齐 |
|
||||
| **题型支持** | 单选/多选/判断/填空/简答/作文/复合题 | `question-renderer.tsx` 支持 take/review/grade 三态 | ✅ 对齐 |
|
||||
| **拍照答题** | 拍照 → AI 自动批改客观题 + 教师主观题批注 | `ScanUploader` 上传 + `HomeworkScanGradingView` 人工阅卷 | ⚠️ 缺 AI 客观题自动识别 |
|
||||
| **错题归集** | 自动同步错题本 + 知识点掌握度更新 | `runPostGradingHooks` 已实现 | ✅ 对齐 |
|
||||
| **结果可视化** | 分数雷达图 + 知识点掌握度热力图 + 班级位次 | `HomeworkSubmissionResult` 仅展示分数 + 错题预览 | ❌ 缺雷达图/位次 |
|
||||
| **离线作答** | PWA 离线全流程 + 自动同步 | 仅草稿缓存,不能离线提交 | ⚠️ 中等差距 |
|
||||
|
||||
### 3.2 教师侧
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 差距 |
|
||||
|------|--------------|----------|------|
|
||||
| **批量批改** | 一键批改全选 + 智能跳转未批改 | `HomeworkBatchGradingView` 已实现 | ✅ 对齐 |
|
||||
| **AI 批改助手** | 自动评分主观题 + 老师一键采纳/修改 | `HomeworkGradingView` 已集成 AiClientProvider | ✅ 对齐 |
|
||||
| **阅卷式批改** | 左侧扫描图 + 右侧评分 + 快捷键导航 | `HomeworkScanGradingView` ResizablePanel 布局 | ✅ 对齐 |
|
||||
| **作业分析** | 错误率热力图 + 难题预警 + 班级对比 | `getHomeworkAssignmentAnalytics` 已实现 + 高错误率预警 | ✅ 基本对齐,缺班级对比 |
|
||||
| **催交提醒** | 一键群发未提交学生 + 家长同步通知 | `sendHomeworkReminderAction` 已存在(见 actions.ts:598+) | ✅ 对齐 |
|
||||
| **优秀作业展示** | 学生可见的"模范作业"墙 + 老师点评 | `getExcellentSubmissionsAction` + `ExcellentSubmissions` 已实现 | ✅ 对齐 |
|
||||
|
||||
### 3.3 家长侧
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 差距 |
|
||||
|------|--------------|----------|------|
|
||||
| **作业进度跟踪** | 显示孩子待完成/已提交/已批改 + 截止时间倒计时 | `getStudentHomeworkAssignments` + `getStudentDashboardGrades` | ⚠️ 缺截止倒计时 |
|
||||
| **逾期提醒** | 顶部红色 banner 显示逾期作业 | parent 模块有 `parent-attention-banner.tsx` 但含硬编码英文 | ⚠️ i18n 不完整 |
|
||||
| **作业详情查看** | 家长可看孩子作答内容 + 教师评语 | 仅展示成绩列表,无详情查看入口 | ❌ 缺失 |
|
||||
|
||||
### 3.4 admin 侧
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 差距 |
|
||||
|------|--------------|----------|------|
|
||||
| **全局作业监控** | 全校作业量趋势 + 教师布置频率 + 班级对比 | 无 admin 专属作业页面 | ❌ 完全缺失 |
|
||||
| **作业质量审计** | 按学科/年级分析作业难度分布 | 无 | ❌ 缺失 |
|
||||
|
||||
### 3.5 通用 UX
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 差距 |
|
||||
|------|--------------|----------|------|
|
||||
| **空状态** | 友好插画 + 引导 CTA | 已实现 `EmptyState` | ✅ 对齐 |
|
||||
| **加载骨架屏** | 列表骨架 + 详情骨架 + 表单骨架 | 部分 loading.tsx 缺失(见 2.1.1) | ❌ 缺失 |
|
||||
| **键盘导航** | 全程键盘可达 | 大部分组件支持,但题目作答无 `tabindex` 序列化 | ⚠️ 中等差距 |
|
||||
| **a11y** | ARIA 属性 + 屏幕阅读器 | 大部分有 `aria-hidden`,缺 `aria-live` 通知 | ⚠️ 中等差距 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### 4.1 P0 - 必须立即修复(违反硬性规则)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|----------|
|
||||
| P0-1 | 13 个路由缺失 `loading.tsx`/`error.tsx` | 为每个缺失路由补全文件,loading 用骨架屏组件,error 用 `EmptyState + retry`,文案接入 i18n |
|
||||
| P0-2 | `homework-take-view.tsx` 520 行 | 拆分为 `HomeworkTakeView`(容器)+ `HomeworkTakeHeader`(标题/倒计时)+ `HomeworkTakeQuestionList`(题目列表)+ `HomeworkTakeSubmitBar`(提交栏)+ `HomeworkTakeConfirmDialog`(确认对话框)+ `HomeworkTakeScanSection`(扫描图区块) |
|
||||
| P0-3 | `schema.ts` 硬编码中文/英文错误消息 | 改用 i18n 错误 code,由 Server Action 在 catch 时通过 `getTranslations` 翻译后再返回 `fieldErrors` |
|
||||
| P0-4 | `ScanAttachment` 类型未沉淀到 `types.ts` | 迁移 `ScanAttachment` 到 `types.ts`,`actions.ts` 改为 `import type` |
|
||||
|
||||
### 4.2 P1 - 应优化(影响代码质量与可维护性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|----------|
|
||||
| P1-1 | `homework-grading-view.tsx` 本地 wrapper | 删除 wrapper,组件内直接 import lib 中的 `isAutoGradableUtil` 等函数 |
|
||||
| P1-2 | `data-access.ts` re-export 子模块 | 调用方改 import 路径为 `./data-access-student`、`./data-access-exam-cross`,删除 re-export |
|
||||
| P1-3 | `assignments/[id]/page.tsx`、`submissions/[submissionId]/page.tsx` 未用 SectionErrorBoundary | 用 `SectionErrorBoundary + Suspense` 包裹独立数据区块 |
|
||||
| P1-4 | `actions.ts` 内联扫描图 DB 查询 | `getScansAction` 中第 492-511 行的 `db.select(...).from(fileAttachments)` 应下沉到 `files/data-access` 或新建 `homework/data-access-scans.ts`,保持 actions 层只做编排 |
|
||||
| P1-5 | 架构图行数信息滞后 | 同步 `004`/`005` 文档行数、新增组件、测试文件 |
|
||||
|
||||
### 4.3 P2 - 长期演进(提升企业级能力)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|----------|
|
||||
| P2-1 | 角色差异硬编码在路由层 | 定义 `HomeworkModuleConfig` 接口,根据角色注入不同 Widget 配置;新增 admin/homework 页面 |
|
||||
| P2-2 | 关键操作埋点不全 | 在 `createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction` / `deleteScanAction` 增加 `trackExamEvent` 埋点 |
|
||||
| P2-3 | `extractQuestionText` 等内联在组件 | 迁移到 `lib/question-content-utils.ts` |
|
||||
| P2-4 | 家长侧无作业详情查看 | 新增 `parent/children/[id]/assignments/[assignmentId]` 路由 |
|
||||
| P2-5 | admin 侧无全局作业监控 | 新增 `admin/homework/page.tsx`,复用 stats-service |
|
||||
| P2-6 | 结果页缺雷达图/班级位次 | `HomeworkSubmissionResult` 接入图表组件 |
|
||||
| P2-7 | 题目作答缺键盘 tabindex | `QuestionRenderer` 添加 `tabIndex` + `onKeyDown` |
|
||||
| P2-8 | AI 客观题自动识别(扫描图) | 长期:接入 OCR + 题型识别模型 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 需要更新的内容
|
||||
|
||||
#### `docs/architecture/004_architecture_impact_map.md` - `## 2.3 homework(作业模块)` 章节
|
||||
|
||||
1. **行数同步**:
|
||||
- `data-access.ts`: 598 → 629(实施 P1-2 后将回到 580 左右)
|
||||
- `actions.ts`: 239+ → 597
|
||||
- `schema.ts`: 29 → 56
|
||||
- `types.ts`: 186 → 257
|
||||
|
||||
2. **新增文件清单**:
|
||||
- `components/homework-take-header.tsx`(拆分自 homework-take-view)
|
||||
- `components/homework-take-question-list.tsx`(拆分自 homework-take-view)
|
||||
- `components/homework-take-submit-bar.tsx`(拆分自 homework-take-view)
|
||||
- `components/homework-take-confirm-dialog.tsx`(拆分自 homework-take-view)
|
||||
- `components/homework-take-scan-section.tsx`(拆分自 homework-take-view)
|
||||
- `lib/question-content-utils.test.ts`(415 行,单测)
|
||||
|
||||
3. **`ScanAttachment` 类型位置变更**:
|
||||
- 现状描述"Types:`ScanAttachment`"需更正:实际位置原在 `actions.ts`,本次审计后迁移到 `types.ts`
|
||||
|
||||
4. **新增已知问题与解决状态**:
|
||||
- ✅ P0-1 已修复:补全 13 个缺失的 loading.tsx/error.tsx
|
||||
- ✅ P0-2 已修复:拆分 homework-take-view.tsx 至 5 个子组件
|
||||
- ✅ P0-3 已修复:schema.ts 错误消息 i18n 化
|
||||
- ✅ P0-4 已修复:ScanAttachment 迁移到 types.ts
|
||||
- ✅ P1-1 已修复:删除 grading-view 本地 wrapper
|
||||
- ✅ P1-2 已修复:清理 data-access.ts re-export
|
||||
- ✅ P1-3 已修复:详情页/批改页接入 SectionErrorBoundary
|
||||
- ✅ P1-4 已修复:扫描图 DB 查询下沉到 data-access-scans.ts
|
||||
|
||||
#### `docs/architecture/005_architecture_data.json`
|
||||
|
||||
- 同步更新 `modules.homework.exports` 中 `types` 数组增加 `ScanAttachment`
|
||||
- 同步 `modules.homework.files` 行数与新增文件
|
||||
- `dependencyMatrix` 无需变更(未引入新的跨模块依赖)
|
||||
|
||||
### 5.2 不需变更的内容
|
||||
|
||||
- 模块导出函数清单(函数签名未变)
|
||||
- 模块依赖关系(仍依赖 shared/auth/exams/classes/school/users/files/error-book/diagnostic)
|
||||
- 数据库表清单(无新增表)
|
||||
|
||||
---
|
||||
|
||||
## 附:实施清单
|
||||
|
||||
本审计报告涉及的实施改动如下(按优先级):
|
||||
|
||||
### P0 实施(本次完成)
|
||||
- [x] 补全 13 个缺失的 `loading.tsx` 与 `error.tsx`
|
||||
- [x] 拆分 `homework-take-view.tsx`(520 → ≤500)
|
||||
- [x] `schema.ts` 错误消息 i18n 化
|
||||
- [x] `ScanAttachment` 迁移到 `types.ts`
|
||||
|
||||
### P1 实施(本次完成)
|
||||
- [x] 删除 `homework-grading-view.tsx` 本地 wrapper
|
||||
- [x] 清理 `data-access.ts` 末尾 re-export
|
||||
- [x] 详情页/批改页接入 `SectionErrorBoundary`
|
||||
- [x] 扫描图 DB 查询下沉到 `data-access-scans.ts`
|
||||
|
||||
### P2 实施(本次完成)
|
||||
- [x] 关键操作埋点补全(create/start/save/submit/grade/deleteScan)
|
||||
- [x] `extractQuestionText` 迁移到 lib
|
||||
|
||||
### 架构图同步
|
||||
- [x] 更新 `004_architecture_impact_map.md`
|
||||
- [x] 更新 `005_architecture_data.json`
|
||||
|
||||
### 中长期计划(文档化,不在本次实施范围)
|
||||
- P2-1 角色配置驱动重构
|
||||
- P2-4 家长侧作业详情
|
||||
- P2-5 admin 全局作业监控
|
||||
- P2-6 结果页雷达图
|
||||
- P2-7 题目作答键盘导航
|
||||
- P2-8 AI 客观题自动识别
|
||||
464
docs/architecture/audit/homework-exams-audit-report.md
Normal file
464
docs/architecture/audit/homework-exams-audit-report.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# 作业和考试模块审计报告
|
||||
|
||||
> 生成时间:2026-06-22
|
||||
> 审计范围:`src/modules/exams/`、`src/modules/homework/`、`src/app/(dashboard)/teacher/exams/`、`src/app/(dashboard)/teacher/homework/`、`src/app/(dashboard)/student/learning/assignments/`、相关共享层
|
||||
> 审计维度:三层架构合规性、文件行数、跨模块依赖、权限校验、国际化、类型安全、错误处理、组件复用性
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 模块文件分布
|
||||
|
||||
作业和考试模块由两个独立但紧密协作的模块组成,共 **68 个源文件**,总代码量约 **14,800 行**。
|
||||
|
||||
#### 考试模块(`src/modules/exams/`)
|
||||
|
||||
| 子目录 | 文件数 | 总行数 | 主要职责 |
|
||||
|--------|--------|--------|----------|
|
||||
| 根目录 | 5 | 2,810 | actions/data-access/types/stats-service/utils |
|
||||
| `ai-pipeline/` | 4 | 1,117 | AI 出题管线(解析/请求/结构/入口) |
|
||||
| `components/` | 22 | 4,856 | 考试 UI 组件(表单/组卷/预览/分析/数据表格) |
|
||||
| `editor/` | 13 | 1,834 | Tiptap 富文本编辑器(节点扩展/工具栏/转换) |
|
||||
| `hooks/` | 1 | 315 | AI 预览状态管理 |
|
||||
| **小计** | **45** | **10,932** | |
|
||||
|
||||
#### 作业模块(`src/modules/homework/`)
|
||||
|
||||
| 子目录 | 文件数 | 总行数 | 主要职责 |
|
||||
|--------|--------|--------|----------|
|
||||
| 根目录 | 7 | 2,778 | actions/data-access(3)/types/schema/stats-service |
|
||||
| `components/` | 16 | 3,806 | 作业 UI 组件(作答/批改/阅卷/结果/复习) |
|
||||
| `hooks/` | 2 | 329 | 自动保存/倒计时 |
|
||||
| `lib/` | 2 | 777 | 题目内容解析纯函数 + 单测 |
|
||||
| **小计** | **27** | **7,690** | |
|
||||
|
||||
#### App 页面
|
||||
|
||||
| 路由 | 文件数 | 说明 |
|
||||
|------|--------|------|
|
||||
| `teacher/exams/` | 10 | 列表/创建/组卷/分析/编辑/监考/批改 |
|
||||
| `teacher/homework/` | 8 | 列表/创建/详情/提交列表/批改/阅卷 |
|
||||
| `student/learning/assignments/` | 3 | 列表/作答/结果 |
|
||||
|
||||
### 1.2 数据流概要
|
||||
|
||||
考试与作业模块通过 `sourceExamId` 形成"考试 → 作业 → 提交 → 批改 → 分析"的完整数据链路:
|
||||
|
||||
```
|
||||
教师创建考试 (exams)
|
||||
└─▶ 教师从考试派生作业 (homework, sourceExamId 关联)
|
||||
└─▶ 学生开始作答 (homeworkSubmissions)
|
||||
└─▶ 学生保存答案 (homeworkAnswers)
|
||||
└─▶ 学生提交 (homeworkSubmissions.status = submitted)
|
||||
└─▶ 教师批改 (homeworkAnswers.score/feedback)
|
||||
└─▶ 考试分析 (exams/stats-service 聚合)
|
||||
└─▶ 错题采集 (error-book 模块)
|
||||
└─▶ 掌握度更新 (diagnostic 模块)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录完整性评估
|
||||
|
||||
**004_architecture_impact_map.md**:已记录考试流程数据流(1.3 节)、AI 出题调用链(1.4.1)、学生提交链路(1.4.2),P1-1 已修复跨模块直查问题。**但未记录** `exams/utils/normalize-structure.ts`(65 行,2026-06-22 新增)和 `homework/data-access-classes.ts` 的完整职责。
|
||||
|
||||
**005_architecture_data.json**:已记录 exams/homework 模块的 actions/dataAccess/tables/dependencyMatrix。**但存在不一致**:文档记录 `homework/data-access.ts` 为 598 行,实际已增长至 1008 行;`exams/actions.ts` 文档未反映已增至 1525 行。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 文件行数严重超标(P0)
|
||||
|
||||
**违反规则**:项目规则「硬性上限:任何文件不超过 1000 行,超过必须拆分」「Server Actions / Data Access 模块:建议 ≤ 800 行」「自定义 Hook:建议 ≤ 80 行」「React 组件:建议 ≤ 500 行」
|
||||
|
||||
| 文件 | 实际行数 | 限制 | 超标程度 | 后果 |
|
||||
|------|---------|------|---------|------|
|
||||
| `exams/actions.ts` | **1525** | 800(硬上限 1000) | **超硬上限 52%** | 维护困难、单测不可行、合并冲突频发 |
|
||||
| `exams/data-access.ts` | **1036** | 800(硬上限 1000) | **超硬上限 4%** | 同上 |
|
||||
| `homework/data-access.ts` | **1008** | 800(硬上限 1000) | **超硬上限 1%** | 同上 |
|
||||
| `exams/hooks/use-exam-preview.ts` | **315** | 80 | **超标 294%** | Hook 职责过多,难以复用和测试 |
|
||||
| `exams/components/exam-rich-form.tsx` | **542** | 500 | 超标 8% | 组件臃肿 |
|
||||
| `exams/components/assembly/structure-editor.tsx` | **771** | 500(放宽 800) | 在放宽范围内 | 可接受但接近上限 |
|
||||
| `homework/components/homework-grading-view.tsx` | **562** | 500(放宽 800) | 在放宽范围内 | 可接受 |
|
||||
| `homework/components/homework-take-view.tsx` | **557** | 500(放宽 800) | 在放宽范围内 | 可接受 |
|
||||
|
||||
**根因**:
|
||||
- `exams/actions.ts`:`autoMarkExamAction`(第 906-1250 行)包含大量纯转换辅助函数(`buildTiptapDocFromAiResponse`、`splitByDottedTexts` 等),应提取到 `ai-pipeline/`;`updateExamFromRichEditorAction`(第 1268-1523 行)直接包含 DB 事务逻辑,应下沉到 data-access
|
||||
- `exams/data-access.ts`:15+ 个导出函数混合了核心 CRUD、跨模块接口、年级仪表盘聚合,未按职责拆分
|
||||
- `homework/data-access.ts`:V3-8/V3-9/V3-11 新增的 4 个跨模块查询函数导致文件再次膨胀
|
||||
|
||||
### 2.2 Server Action 直接操作数据库(P0)
|
||||
|
||||
**违反规则**:项目规则「app/ 只能调用 modules/ 的 Server Actions 和 data-access,不直接访问 DB」「actions 层移除直接 DB 操作」
|
||||
|
||||
**位置**:`exams/actions.ts` 第 1492-1511 行,`updateExamFromRichEditorAction` 内部直接执行数据库事务:
|
||||
|
||||
```typescript
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(exams).set({...}).where(eq(exams.id, input.examId))
|
||||
await tx.delete(examQuestions).where(eq(examQuestions.examId, input.examId))
|
||||
if (orderedQuestions.length > 0) {
|
||||
await tx.insert(examQuestions).values(...)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**后果**:破坏三层架构分层,data-access 层无法统一控制 DB 访问,事务逻辑无法被其他 action 复用,单测需要 mock 整个 db 模块。
|
||||
|
||||
### 2.3 跨模块依赖违规(P1)
|
||||
|
||||
**违反规则**:项目规则「modules/ 之间通过对方 data-access 通信,不直接查询对方 DB 表」
|
||||
|
||||
**位置**:`exams/stats-service.ts` 第 12 行
|
||||
|
||||
```typescript
|
||||
import { getQuestionText } from "@/modules/homework/lib/question-content-utils"
|
||||
```
|
||||
|
||||
此导入来自 `homework/lib/` 而非 `homework/data-access`,绕过了模块的数据访问层封装。
|
||||
|
||||
**后果**:模块封装性受损,`homework/lib/` 的内部实现变更会直接影响 `exams` 模块。
|
||||
|
||||
### 2.4 RSC 页面权限校验缺失(P0)
|
||||
|
||||
**违反规则**:项目规则「所有 Server Action 必须调用 requirePermission() 进行权限校验」「所有敏感数据查询必须在 data-access 层结合当前用户权限过滤」
|
||||
|
||||
#### 考试模块页面权限缺失
|
||||
|
||||
| 页面 | 缺失权限点 | 后果 |
|
||||
|------|-----------|------|
|
||||
| `teacher/exams/all/page.tsx` | `EXAM_READ` | 任何登录用户可查看考试列表 |
|
||||
| `teacher/exams/create/page.tsx` | `EXAM_CREATE` | 无创建权限用户可访问创建页 |
|
||||
| `teacher/exams/new/page.tsx` | `EXAM_CREATE` | 同上 |
|
||||
| `teacher/exams/[id]/analytics/page.tsx` | `EXAM_READ` | 可查看他人考试分析 |
|
||||
|
||||
#### 作业模块页面 scope 缺失
|
||||
|
||||
| 页面 | 问题 | 后果 |
|
||||
|------|------|------|
|
||||
| `teacher/homework/assignments/page.tsx` | 未传 scope | 仅按 creatorId 过滤,未应用 dataScope |
|
||||
| `teacher/homework/submissions/page.tsx` | 未传 scope | 同上 |
|
||||
| `teacher/homework/assignments/[id]/page.tsx` | 未传 scope | 可通过猜测 ID 查看他人作业分析 |
|
||||
| `teacher/homework/assignments/[id]/submissions/page.tsx` | 未传 scope | 可查看他人作业提交列表 |
|
||||
| `teacher/homework/submissions/[submissionId]/page.tsx` | 无 scope | 可查看他人提交详情 |
|
||||
| `teacher/homework/submissions/[submissionId]/scan-grading/page.tsx` | 无 scope | 同上 |
|
||||
|
||||
**根因**:data-access 函数已支持 `scope?: DataScope` 参数,但页面层未调用 `getAuthContext()` 获取 dataScope 并传递。
|
||||
|
||||
### 2.5 类型安全问题(P1)
|
||||
|
||||
**违反规则**:项目规则「禁止 as 断言(除非从 unknown 转换或测试中,需注释原因)」「可选链后禁止跟非空断言 !」
|
||||
|
||||
#### `as never` 断言(最严重,完全绕过类型检查)— 8 处
|
||||
|
||||
| 文件 | 行号 | 代码 |
|
||||
|------|------|------|
|
||||
| `exams/actions.ts` | 1306 | `editorDocToStructure(editorDoc as never, input.title)` |
|
||||
| `exams/actions.ts` | 1314 | `content: q.content as never` |
|
||||
| `exams/actions.ts` | 1414 | `editorDocToStructure(editorDoc as never, input.title)` |
|
||||
| `exams/actions.ts` | 1452 | `content: q.content as never` |
|
||||
| `exams/actions.ts` | 1453 | `q.type as "single_choice" \| ...` |
|
||||
| `exams/editor/extensions/group-block.tsx` | 25, 34 | `block as never` |
|
||||
| `exams/editor/extensions/section-block.tsx` | 26, 35 | `block as never` |
|
||||
|
||||
**后果**:类型安全完全失效,运行时错误风险高。
|
||||
|
||||
#### 非 unknown 的 `as` 断言 — 12+ 处
|
||||
|
||||
| 文件 | 行号 | 断言类型 |
|
||||
|------|------|---------|
|
||||
| `exams/editor/editor-to-structure.ts` | 101 | `as RichQuestionType` |
|
||||
| `exams/editor/editor-to-structure.ts` | 81, 85 | 非空断言 `match[1]!`、`match[2]!` |
|
||||
| `exams/editor/exam-rich-editor.tsx` | 155, 171 | `as EditorJSONContent` |
|
||||
| `exams/editor/exam-rich-editor.tsx` | 245 | `as QuestionBlockType` |
|
||||
| `exams/editor/selection-toolbar.tsx` | 211, 213 | `as JSONContent[]`、`as JSONContent` |
|
||||
| `exams/components/exam-form.tsx` | 40 | `as Resolver<ExamFormValues>` |
|
||||
| `exams/components/exam-rich-form.tsx` | 148 | `as EditorJSONContent` |
|
||||
| `exams/components/exam-data-table.tsx` | 39 | `as Record<string, string \| number \| Date>` |
|
||||
| `exams/components/assembly/structure-editor.tsx` | 459, 480, 481, 648, 649 | `as string`(从 DragEndEvent) |
|
||||
|
||||
**正面发现**:全模块未发现 `any` 类型使用 ✅;从 unknown 转换的 `as`(15+ 处)符合规则 ✅。
|
||||
|
||||
### 2.6 国际化严重遗漏(P1)
|
||||
|
||||
**违反规则**:项目规则「所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键」
|
||||
|
||||
#### 考试模块 i18n 覆盖情况
|
||||
|
||||
| 覆盖状态 | 文件数 | 文件列表 |
|
||||
|---------|--------|---------|
|
||||
| ✅ 完全覆盖 | 8 | exam-form, exam-actions, exam-analytics-dashboard, exam-assembly, structure-editor, all/page, create/page, new/page |
|
||||
| ❌ 完全未覆盖 | 7 | use-exam-preview.ts, exam-rich-editor.tsx, selection-toolbar.tsx, exam-preview-question-editor.tsx, question-sub-questions-editor.tsx, selected-question-list.tsx, exam-paper-preview.tsx |
|
||||
| ⚠️ 部分覆盖 | 4 | exam-rich-form.tsx, actions.ts, proctoring/page.tsx, exam-form-types.ts |
|
||||
|
||||
**典型硬编码示例**:
|
||||
- `hooks/use-exam-preview.ts`:全部 toast 消息为硬编码中文(「页面刷新后任务已中断」「未命名试卷」「已加入后台队列」等 10+ 条)
|
||||
- `editor/selection-toolbar.tsx`:工具栏标签全部硬编码(「分卷」「大题」「单选」「填空/简答」「复合」「加点字」「填空」「图片」)
|
||||
- `components/assembly/selected-question-list.tsx`:完全硬编码英文(「No questions selected」「Create Group」「Create Section」等)
|
||||
|
||||
#### 作业模块 i18n 覆盖情况
|
||||
|
||||
| 覆盖状态 | 文件数 | 文件列表 |
|
||||
|---------|--------|---------|
|
||||
| ✅ 完全覆盖 | 9 | homework-take-view, homework-assignment-form, homework-grading-view, homework-batch-grading-view, homework-scan-grading-view, homework-submission-result, student-homework-review-view, question-renderer, scan-uploader |
|
||||
| ❌ 完全未覆盖 | 7 | assignment-filters, homework-assignment-exam-content-card, homework-assignment-exam-preview-pane, homework-assignment-question-error-detail-panel, homework-assignment-question-error-overview-card, homework-assignment-exam-error-explorer-lazy, scan-image-viewer |
|
||||
| ⚠️ App 页面未覆盖 | 4 | submissions/page, assignments/create/page, assignments/[id]/page, submissions/[submissionId]/page |
|
||||
|
||||
**估算总 i18n 覆盖率**:约 48%(考试 40%,作业 55%)
|
||||
|
||||
### 2.7 组件代码重复(P2)
|
||||
|
||||
**违反规则**:项目规则「组件必须为纯函数」和 DRY 原则
|
||||
|
||||
| 重复代码 | 位置 | 应提取到 |
|
||||
|---------|------|---------|
|
||||
| `QuestionContent` + `Answer` 类型 | homework-grading-view.tsx:537-544, homework-scan-grading-view.tsx:23-35 | `homework/types.ts` |
|
||||
| `formatStudentAnswer` 函数 | homework-grading-view.tsx:537-544 | 已存在于 `lib/question-content-utils.ts:335-343`,应直接导入 |
|
||||
| `isRecord` + `getOptions` 函数 | homework-assignment-question-error-detail-panel.tsx:6-21 | 已存在于 `lib/question-content-utils.ts`,应直接导入 |
|
||||
|
||||
### 2.8 共享层缺失组件(P2)
|
||||
|
||||
**违反规则**:项目规则要求最大化复用
|
||||
|
||||
| 缺失组件 | 现状 | 后果 |
|
||||
|---------|------|------|
|
||||
| 共享 ErrorBoundary | 6 个模块各自重复实现(lesson-preparation/textbooks/ai/grades/school/settings) | 代码重复,行为不一致 |
|
||||
| PermissionGuard | 不存在,仅通过 hook + 中间件 | 无统一的"无权限"UI 状态组件 |
|
||||
|
||||
### 2.9 导航与图片组件不规范(P2)
|
||||
|
||||
**违反规则**:项目惯例使用 Next.js `<Link>` 和 `<Image>`
|
||||
|
||||
| 文件 | 行号 | 问题 |
|
||||
|------|------|------|
|
||||
| `homework-batch-grading-view.tsx` | 154-165 | 使用 `<a>` 而非 `<Link>` |
|
||||
| `scan-uploader.tsx` | 208-211 | 使用 `<img>` 而非 `<Image>`(有 eslint-disable) |
|
||||
| `scan-image-viewer.tsx` | 140-145, 192-196 | 使用 `<img>` 而非 `<Image>`(有 eslint-disable) |
|
||||
|
||||
### 2.10 data-access re-export 代码异味(P3)
|
||||
|
||||
**位置**:`homework/data-access.ts` 第 1000-1008 行
|
||||
|
||||
```typescript
|
||||
// Re-export stats functions for backward compatibility
|
||||
export { getTeacherGradeTrends, ... } from "./stats-service"
|
||||
```
|
||||
|
||||
注释明确指出"New code should import directly from ./stats-service",但保留 re-export 仅为向后兼容,增加了文件行数。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 对标产品矩阵
|
||||
|
||||
结合 K12 教育系统特点,对标智学网、班级小管家、猿题库、Google Classroom、Canvas LMS 五款产品:
|
||||
|
||||
| 功能维度 | 智学网 | 班级小管家 | Google Classroom | Canvas LMS | 当前实现 | 差距评估 |
|
||||
|---------|--------|-----------|------------------|------------|---------|---------|
|
||||
| 即时自动批改 | ✅ 提交即出分 | ❌ 需教师批改 | ❌ 需教师批改 | ✅ 可配置 | ❌ 仅批改页计算 | **P0 差距** |
|
||||
| 批量批改 | ✅ 多选+批量 | ✅ 逐份批改 | ❌ 无 | ✅ 批量打分 | ⚠️ 已实现但 UI 不完整 | P1 差距 |
|
||||
| 考试分析 | ✅ 难度/区分度/知识点 | ❌ 基础统计 | ❌ 基础统计 | ✅ 完整分析 | ⚠️ 作业有分析,考试已新增 | P1 差距 |
|
||||
| 多选题部分分 | ✅ 漏选得部分分 | ❌ 全对才得分 | ❌ 全对才得分 | ✅ 可配置 | ❌ 全对才得分 | P1 差距 |
|
||||
| 提交后反馈 | ✅ 即时显示 | ❌ 跳转列表 | ❌ 等待教师 | ✅ 即时显示 | ✅ 已实现 result 页 | 已达标 |
|
||||
| 错题本 | ✅ 自动归集 | ❌ 无 | ❌ 无 | ✅ 可导出 | ✅ 已实现 error-book | 已达标 |
|
||||
| 家长视图 | ✅ 考试详情+趋势 | ✅ 作业查看 | ❌ 无 | ✅ 观察员模式 | ⚠️ 仅作业摘要 | P2 差距 |
|
||||
| 移动端触控 | ✅ 原生 App | ✅ 小程序 | ✅ 响应式 | ✅ 响应式 | ⚠️ 响应式但触控未优化 | P3 差距 |
|
||||
| 优秀作业展示 | N/A | ✅ 置顶+全班可见 | ❌ 无 | ❌ 无 | ❌ 无 | P2 差距 |
|
||||
| 作业催交提醒 | ✅ 自动提醒 | ✅ 一键催交 | ❌ 无 | ✅ 通知 | ❌ 无 | P2 差距 |
|
||||
|
||||
### 3.2 关键差距分析
|
||||
|
||||
#### 差距 1:即时自动批改回写(P0)
|
||||
|
||||
**当前流程**:学生提交 → 跳转列表 → 教师打开批改页 → 客户端计算 → 教师手动提交成绩
|
||||
|
||||
**行业实践**:智学网/猿题库在学生提交瞬间服务端自动批改客观题,学生立即看到分数。
|
||||
|
||||
**影响**:学生提交后看不到即时成绩,体验割裂;若教师不打开批改页,客观题永远不会有分数。
|
||||
|
||||
#### 差距 2:批量批改 UI 不完整(P1)
|
||||
|
||||
**当前**:`homework-batch-grading-view.tsx`(176 行)已实现批量自动批改,但缺少批量设置分数(全对/全错/自定义)功能。
|
||||
|
||||
**行业实践**:智学网支持列表页勾选多份提交,批量设置分数。
|
||||
|
||||
#### 差距 3:多选题部分分(P1)
|
||||
|
||||
**当前**:`computeIsCorrect` 对多选题采用"全对才得分"策略。
|
||||
|
||||
**行业实践**:智学网/猿题库支持"漏选得部分分"。
|
||||
|
||||
#### 差距 4:优秀作业展示(P2)
|
||||
|
||||
**当前**:无优秀作业展示功能。
|
||||
|
||||
**行业实践**:班级小管家支持优秀作业置顶并让全班查看,激励学生。
|
||||
|
||||
#### 差距 5:作业催交提醒(P2)
|
||||
|
||||
**当前**:无催交功能,教师只能手动通知未提交学生。
|
||||
|
||||
**行业实践**:智学网/班级小管家支持一键催交,自动发送通知给未提交学生。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### 4.1 P0 优先级(架构合规,必须立即修复)
|
||||
|
||||
| 编号 | 改进项 | 实现方案 | 影响范围 |
|
||||
|------|--------|---------|---------|
|
||||
| P0-1 | 拆分 `exams/actions.ts`(1525→≤800) | 提取 `autoMarkExamAction` 辅助函数到 `ai-pipeline/auto-mark.ts`;提取 DB 事务到 `data-access` | exams 模块 |
|
||||
| P0-2 | 拆分 `exams/data-access.ts`(1036→≤800) | 按职责拆分为 `data-access.ts`(核心 CRUD)+ `data-access-cross-module.ts`(跨模块接口) | exams 模块 |
|
||||
| P0-3 | 拆分 `homework/data-access.ts`(1008→≤800) | 提取跨模块函数到 `data-access-exam-cross.ts`;学生视角到 `data-access-student.ts` | homework 模块 |
|
||||
| P0-4 | 修复 Server Action 直接操作 DB | 将 `updateExamFromRichEditorAction` 的事务逻辑下沉到 `data-access` | exams/actions.ts |
|
||||
| P0-5 | 补全考试页面权限校验 | 4 个页面添加 `requirePermission()` | teacher/exams/ |
|
||||
| P0-6 | 补全作业页面 scope 传递 | 6 个页面调用 `getAuthContext()` 并传递 scope | teacher/homework/ |
|
||||
|
||||
### 4.2 P1 优先级(重要体验与安全)
|
||||
|
||||
| 编号 | 改进项 | 实现方案 | 影响范围 |
|
||||
|------|--------|---------|---------|
|
||||
| P1-1 | 修复跨模块依赖 | `exams/stats-service.ts` 改为从 `homework/data-access` 导入 `getQuestionText` | exams/stats-service.ts |
|
||||
| P1-2 | 消除 `as never` 断言 | 8 处改用类型守卫或正确类型签名 | exams/actions.ts, editor/extensions/ |
|
||||
| P1-3 | 考试模块 i18n 补全 | 7 个完全未覆盖文件 + 4 个部分覆盖文件提取翻译键 | exams 模块 |
|
||||
| P1-4 | 作业模块 i18n 补全 | 7 个组件 + 4 个 app 页面提取翻译键 | homework 模块 |
|
||||
| P1-5 | 提取共享 ErrorBoundary | 创建 `shared/components/error-boundary.tsx`,迁移 6 个模块重复实现 | shared + 6 模块 |
|
||||
| P1-6 | 代码去重 | 提取 `QuestionContent`/`Answer` 类型到 types.ts;删除重复函数 | homework 模块 |
|
||||
|
||||
### 4.3 P2 优先级(增强体验)
|
||||
|
||||
| 编号 | 改进项 | 实现方案 | 影响范围 |
|
||||
|------|--------|---------|---------|
|
||||
| P2-1 | 拆分 `use-exam-preview.ts`(315→≤80) | 拆分为预览状态/后台任务/AI 重写三个 hook | exams/hooks/ |
|
||||
| P2-2 | 拆分 `exam-rich-form.tsx`(542→≤500) | 提取 `ExamPreview` 子组件为独立文件 | exams/components/ |
|
||||
| P2-3 | 替换 `<a>` 为 `<Link>` | homework-batch-grading-view.tsx | homework/components/ |
|
||||
| P2-4 | 移除 re-export 代码异味 | 排查调用方后移除 homework/data-access.ts 尾部 re-export | homework/ |
|
||||
| P2-5 | 即时自动批改回写 | `markHomeworkSubmitted` 中调用 `applyAutoGrades` 并回写 DB | homework/data-access-write.ts |
|
||||
| P2-6 | 多选题部分分 | `applyAutoGrades` 增加部分分计算策略 | homework/lib/ |
|
||||
|
||||
### 4.4 P3 优先级(细节优化,中长期)
|
||||
|
||||
| 编号 | 改进项 | 实现方案 | 影响范围 |
|
||||
|------|--------|---------|---------|
|
||||
| P3-1 | 优秀作业展示 | 新增 `homework/components/excellent-submissions.tsx` | homework 模块 |
|
||||
| P3-2 | 作业催交提醒 | 新增 `homework/actions.remindUnsubmittedAction` + 通知 | homework + notifications |
|
||||
| P3-3 | 移动端触控优化 | 题目导航按钮调整为 44px 最小触控目标 | homework-take-view.tsx |
|
||||
| P3-4 | 家长考试详情视图 | 新增 `parent/components/child-exam-detail.tsx` | parent 模块 |
|
||||
| P3-5 | `<img>` 迁移 `<Image>` | 评估 scan-uploader/scan-image-viewer 迁移可行性 | homework/components/ |
|
||||
|
||||
### 4.5 实施顺序
|
||||
|
||||
```
|
||||
第一阶段(P0 架构合规):
|
||||
P0-1 → P0-2 → P0-3 → P0-4 → P0-5 → P0-6
|
||||
(先拆分大文件,再修复 DB 直访,最后补权限)
|
||||
|
||||
第二阶段(P1 安全与体验):
|
||||
P1-1 → P1-2 → P1-3 → P1-4 → P1-5 → P1-6
|
||||
(先修复跨模块依赖和类型安全,再补 i18n,最后提取共享组件)
|
||||
|
||||
第三阶段(P2 增强):
|
||||
P2-1 → P2-2 → P2-3 → P2-4 → P2-5 → P2-6
|
||||
(先拆分剩余超标文件,再实现即时批改)
|
||||
|
||||
第四阶段(P3 中长期):
|
||||
P3-1 → P3-2 → P3-3 → P3-4 → P3-5
|
||||
(优秀作业、催交、移动端、家长视图、图片优化)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 需要补充的节点
|
||||
|
||||
本次审计发现架构图存在以下遗漏和不一致,需同步更新:
|
||||
|
||||
#### 004_architecture_impact_map.md 需更新
|
||||
|
||||
1. **exams 模块章节**:
|
||||
- 新增 `utils/normalize-structure.ts`(65 行,已存在但未记录)
|
||||
- 更新 `actions.ts` 行数记录(1525 行,超硬上限)
|
||||
- 更新 `data-access.ts` 行数记录(1036 行,超硬上限)
|
||||
- 记录 `updateExamFromRichEditorAction` 中的 DB 直访问题(待修复)
|
||||
|
||||
2. **homework 模块章节**:
|
||||
- 更新 `data-access.ts` 行数记录(1008 行,超硬上限,文档记录为 598 行已过时)
|
||||
- 记录 `data-access-classes.ts` 完整职责(跨模块查询封装)
|
||||
- 记录 `data-access-error-collection.ts`(错题采集接口)
|
||||
|
||||
3. **跨模块依赖**:
|
||||
- 记录 `exams/stats-service.ts` → `homework/lib/question-content-utils` 的违规依赖(待修复)
|
||||
|
||||
#### 005_architecture_data.json 需更新
|
||||
|
||||
1. **modules.exams** 节点:
|
||||
- 更新 `actions` 数组,补充 `autoMarkExamAction`、`createExamFromRichEditorAction`、`updateExamFromRichEditorAction` 的完整 deps
|
||||
- 新增 `utils` 子节点,记录 `normalize-structure.ts`
|
||||
- 更新 `dataAccess` 数组行数和职责描述
|
||||
|
||||
2. **modules.homework** 节点:
|
||||
- 更新 `dataAccess` 行数记录
|
||||
- 补充 `dataAccessClasses` 和 `dataAccessErrorCollection` 子节点
|
||||
- 更新 `dependencyMatrix`,记录 exams → homework/lib 的违规依赖
|
||||
|
||||
3. **dbTables** 节点:
|
||||
- 确认 `exams`/`homeworkAssignments`/`homeworkSubmissions` 等表的 `usedBy` 已正确记录(当前已正确)
|
||||
|
||||
### 5.2 同步时机
|
||||
|
||||
- P0 修复完成后:同步更新文件行数和拆分后的新文件
|
||||
- P1-1 修复完成后:更新跨模块依赖记录
|
||||
- P1-5 完成后:记录共享 ErrorBoundary 组件
|
||||
|
||||
---
|
||||
|
||||
## 六、实施记录
|
||||
|
||||
> 以下部分记录审计文档中所有改进项的实施情况。每个改进项完成后更新状态。
|
||||
|
||||
### 6.1 P0 实施记录
|
||||
|
||||
| 编号 | 改进项 | 状态 | 实施说明 |
|
||||
|------|--------|------|---------|
|
||||
| P0-1 | 拆分 exams/actions.ts | ⏳ 待实施 | |
|
||||
| P0-2 | 拆分 exams/data-access.ts | ⏳ 待实施 | |
|
||||
| P0-3 | 拆分 homework/data-access.ts | ⏳ 待实施 | |
|
||||
| P0-4 | 修复 Server Action 直接操作 DB | ⏳ 待实施 | |
|
||||
| P0-5 | 补全考试页面权限校验 | ⏳ 待实施 | |
|
||||
| P0-6 | 补全作业页面 scope 传递 | ⏳ 待实施 | |
|
||||
|
||||
### 6.2 P1 实施记录
|
||||
|
||||
| 编号 | 改进项 | 状态 | 实施说明 |
|
||||
|------|--------|------|---------|
|
||||
| P1-1 | 修复跨模块依赖 | ⏳ 待实施 | |
|
||||
| P1-2 | 消除 as never 断言 | ⏳ 待实施 | |
|
||||
| P1-3 | 考试模块 i18n 补全 | ⏳ 待实施 | |
|
||||
| P1-4 | 作业模块 i18n 补全 | ⏳ 待实施 | |
|
||||
| P1-5 | 提取共享 ErrorBoundary | ⏳ 待实施 | |
|
||||
| P1-6 | 代码去重 | ⏳ 待实施 | |
|
||||
|
||||
### 6.3 P2 实施记录
|
||||
|
||||
| 编号 | 改进项 | 状态 | 实施说明 |
|
||||
|------|--------|------|---------|
|
||||
| P2-1 | 拆分 use-exam-preview.ts | ⏳ 待实施 | |
|
||||
| P2-2 | 拆分 exam-rich-form.tsx | ⏳ 待实施 | |
|
||||
| P2-3 | 替换 `<a>` 为 `<Link>` | ⏳ 待实施 | |
|
||||
| P2-4 | 移除 re-export 代码异味 | ⏳ 待实施 | |
|
||||
| P2-5 | 即时自动批改回写 | ⏳ 待实施 | |
|
||||
| P2-6 | 多选题部分分 | ⏳ 待实施 | |
|
||||
|
||||
### 6.4 P3 实施记录(中长期)
|
||||
|
||||
| 编号 | 改进项 | 状态 | 实施说明 |
|
||||
|------|--------|------|---------|
|
||||
| P3-1 | 优秀作业展示 | ✅ 已完成 | 新增 `homework/components/excellent-submissions.tsx`(206 行,async 服务端组件 + SectionErrorBoundary + Suspense + 骨架屏);`homework/data-access.ts::getExcellentSubmissions`(按得分率过滤、同一学生取最高分、按百分比降序);`homework/actions.ts::getExcellentSubmissionsAction`(HOMEWORK_GRADE 权限 + scope 过滤 + 埋点 `homework.excellent_viewed`);`homework/types.ts` 新增 `ExcellentSubmissionItem`/`ExcellentSubmissionQuery` 类型;i18n 键 `examHomework.homework.excellent.*`(zh-CN + en 双语) |
|
||||
| P3-2 | 作业催交提醒 | ✅ 已完成 | 新增 `homework/data-access.ts::getUnsubmittedStudents`(对比 targets 与已提交学生集合返回差集);`homework/actions.ts::remindUnsubmittedAction`(HOMEWORK_GRADE 权限 + scope 过滤 + 调用 `notifications/data-access.createNotification` 创建 type=homework/priority=high 站内通知 + Promise.allSettled 容错统计 + 埋点 `homework.remind_unsubmitted`);`shared/lib/track-event.ts` EventName 新增 `homework.excellent_viewed`/`homework.remind_unsubmitted` |
|
||||
| P3-3 | 移动端触控优化 | ✅ 已验证 | `homework-take-view.tsx` 导航按钮已使用 `h-11 w-11`(44px)触控目标,符合 a11y 规范 |
|
||||
| P3-4 | 家长考试详情视图 | ✅ 已完成 | 重写 `parent/components/child-exam-detail.tsx`(165 行):所有硬编码英文文案改为 i18n 翻译键 `examHomework.homework.parentExam.*`(zh-CN + en 双语);新增 ChevronRight 导航图标;ul/li 语义化标签 + ARIA 属性;触控目标 ≥ 44px(min-h-[44px]) |
|
||||
| P3-5 | `<img>` 迁移 `<Image>` | ✅ 已完成 | `homework/components/scan-uploader.tsx`:`<img>` → `<Image fill sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw">`;`homework/components/scan-image-viewer.tsx`:缩略图 `<img>` → `<Image fill sizes="48px">`(主查看器因复杂 CSS transforms 保留 `<img>`);`exams/components/exam-preview.tsx`:题目图片 `<img>` → `<Image fill sizes="128px" object-contain>` |
|
||||
|
||||
324
docs/architecture/audit/lesson-preparation-audit-report-v3.md
Normal file
324
docs/architecture/audit/lesson-preparation-audit-report-v3.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# 备课模块审计报告 v3
|
||||
|
||||
> 审计日期:2026-06-24
|
||||
> 审计范围:`src/modules/lesson-preparation/` 全部文件 + `src/app/(dashboard)/{teacher,admin,student,parent}/lesson-plans/` 全部页面
|
||||
> 审计依据:`e:\Desktop\CICD\.trae\rules\project_rules.md`、`docs/architecture/004_architecture_impact_map.md` §2.27、`docs/architecture/005_architecture_data.json` modules.lesson_preparation
|
||||
> 前序报告:`lesson-preparation-audit-report-v2.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
备课模块共 45 个文件,分布如下:
|
||||
|
||||
| 层级 | 文件数 | 主要文件 |
|
||||
|------|--------|----------|
|
||||
| types/schema/constants | 3 | types.ts(345行)、schema.ts(63行)、constants.ts(117行) |
|
||||
| data-access | 4 | data-access.ts(593行)、data-access-versions.ts(181行)、data-access-templates.ts(129行)、data-access-knowledge.ts(95行) |
|
||||
| actions | 4 | actions.ts(391行)、actions-publish.ts(82行)、actions-ai.ts(46行)、actions-kp.ts(47行) |
|
||||
| services | 2 | publish-service.ts(198行)、ai-suggest.ts(82行) |
|
||||
| lib | 6 | type-guards.ts(213行)、i18n-errors.ts(50行)、document-migration.ts(330行)、anchor-injector.ts(304行)、node-summary.ts(133行)、rf-mappers.ts(172行) |
|
||||
| config | 1 | block-registry.tsx(194行) |
|
||||
| providers | 2 | lesson-plan-provider.tsx(336行)、lesson-plan-provider-setup.tsx(29行) |
|
||||
| services | 1 | default-data-service.ts(165行) |
|
||||
| hooks | 1 | use-lesson-plan-editor.ts(303行) |
|
||||
| components | 18 | 含 11 个 block 组件 + 4 个 node 组件 + 7 个业务组件 |
|
||||
| seed | 1 | seed-templates.ts(9行) |
|
||||
| 页面 | 13 | teacher(3) + admin(2) + student(2) + parent(2) + loading/error(4) |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
页面(Server Component) → getAuthContext() → data-access → DB
|
||||
↓
|
||||
LessonPlanProviderSetup → LessonPlanProvider
|
||||
↓
|
||||
LessonPlanEditor(LessonPlanList) → useLessonPlanContextSafe()
|
||||
↓
|
||||
default-data-service → Server Actions → requirePermission → data-access → DB
|
||||
```
|
||||
|
||||
### 1.3 架构图完整性
|
||||
|
||||
架构影响地图 §2.27 记录的导出函数、文件清单、依赖关系与实际代码**基本一致**,但存在以下遗漏:
|
||||
- `providers/lesson-plan-provider-setup.tsx` 已在 V3 续审计中补充
|
||||
- `components/nodes/anchor-node-selector.tsx` 和 `textbook-segments.tsx` 已在 V3 续审计中补充
|
||||
- `node-edit-panel.tsx` 对 `@/modules/ai` 的直接依赖**未在架构图依赖关系中记录**
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 安全与权限问题(P0)
|
||||
|
||||
#### 问题 1:Parent/Student 路由未校验孩子/班级归属 — 信息泄露漏洞
|
||||
|
||||
- **位置**:`data-access.ts` 第 197-204 行 `buildScopeCondition`
|
||||
- **问题**:`children` 和 `class_members` 类型的 DataScope 仅过滤 `status = 'published'`,**完全未使用 `scope.childrenIds`/`scope.classIds`/`scope.gradeIds` 进行归属过滤**
|
||||
- **违反规则**:项目规则 "Parent routes must include permission checks with both parentId and studentId to prevent information leakage"
|
||||
- **后果**:任何家长可查看全校所有已发布课案;任何学生可查看全校所有已发布课案,构成信息泄露
|
||||
|
||||
#### 问题 2:`createLessonPlanVersion` 未校验 planId 归属
|
||||
|
||||
- **位置**:`data-access-versions.ts` 第 56-82 行
|
||||
- **问题**:函数接收 `planId` 和 `userId`,但事务内只查询 `lessonPlanVersions` 表的 max(versionNo),**未校验该 planId 是否属于 userId**
|
||||
- **违反规则**:项目规则 "All Server Actions must call requirePermission() for permission verification" + "软删除/权限过滤在 data-access 层结合 userId 过滤"
|
||||
- **后果**:调用方传入任意 planId 即可为他人课案创建版本记录,越权写入
|
||||
|
||||
#### 问题 3:`pruneAutoVersions` 完全无权限校验
|
||||
|
||||
- **位置**:`data-access-versions.ts` 第 152-181 行
|
||||
- **问题**:函数签名 `pruneAutoVersions(planId, keep = 50)` **没有 userId 参数**,任何调用方传入 planId 即可删除该课案的自动版本记录
|
||||
- **违反规则**:同问题 2
|
||||
- **后果**:越权删除他人课案的版本历史
|
||||
|
||||
#### 问题 4:`getLessonPlansByKnowledgePoint`/`getLessonPlansByQuestion` 无权限过滤
|
||||
|
||||
- **位置**:`data-access-knowledge.ts` 第 11-95 行
|
||||
- **问题**:两个函数直接 `db.select().from(lessonPlans)` 查询全表,**未过滤 creatorId,也未过滤 status(archived 课案也会被查出)**
|
||||
- **违反规则**:同问题 2
|
||||
- **后果**:任意调用方可获取所有课案(含他人 draft、archived 状态)的列表,严重越权
|
||||
|
||||
#### 问题 5:`saveLessonPlanVersionAction` 的 schema 不包含 content 字段
|
||||
|
||||
- **位置**:`actions.ts` 第 154-178 行、`schema.ts` 第 26-28 行
|
||||
- **问题**:`saveVersionSchema` 仅校验 `planId` 和 `label`,**完全不包含 `content` 字段**,`input.content`(类型为 `LessonPlanDocument`)未经任何运行时校验直接持久化
|
||||
- **违反规则**:项目规则 "输入使用 Zod 验证,验证失败返回结构化错误"
|
||||
- **后果**:恶意或畸形文档结构可被写入数据库
|
||||
|
||||
#### 问题 6:`publishLessonPlanHomeworkAction` 中 homeworkTitle 传入 planId
|
||||
|
||||
- **位置**:`actions-publish.ts` 第 35 行
|
||||
- **问题**:`homeworkTitle = t("publish.homeworkTitle", { title: parsed.data.planId })` — `title` 参数传入的是 `planId`(UUID),而非课案标题
|
||||
- **违反规则**:业务逻辑正确性
|
||||
- **后果**:作业标题将包含 UUID 而非有意义的课案名称
|
||||
|
||||
#### 问题 7:`getLessonPlansAction` 的 params 未经验证
|
||||
|
||||
- **位置**:`actions.ts` 第 43-53 行
|
||||
- **问题**:`params`(含 `query`/`textbookId`/`chapterId`/`subjectId`/`status`)**未经过 Zod 验证**直接传入 `getLessonPlans()`
|
||||
- **违反规则**:项目规则 "输入使用 Zod 验证"
|
||||
- **后果**:若 `query` 用于 SQL LIKE 查询且未调用 `escapeLikePattern()`,存在 LIKE 通配符注入风险
|
||||
|
||||
### 2.2 架构违规问题(P0/P1)
|
||||
|
||||
#### 问题 8:`node-edit-panel.tsx` 直接 import `@/modules/ai`
|
||||
|
||||
- **位置**:`node-edit-panel.tsx` 第 11-12 行
|
||||
- **问题**:直接 import `AiLessonContentGenerator` 和 `useAiClientOptional`,形成模块间紧耦合
|
||||
- **违反规则**:项目规则 "模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)"
|
||||
- **后果**:模块间紧耦合,无法独立测试,AI 模块变更影响备课模块
|
||||
|
||||
#### 问题 9:admin/student/parent 列表页未包裹 `LessonPlanProviderSetup`
|
||||
|
||||
- **位置**:`admin/lesson-plans/page.tsx`、`student/lesson-plans/page.tsx`、`parent/lesson-plans/page.tsx`
|
||||
- **问题**:`LessonPlanList` 调用 `useLessonPlanContextSafe()` 获取 `service`,当未在 Provider 内使用时 `service` 为 `null`,导致 `handleFilter` 静默返回 — **筛选功能在 admin/student/parent 页面完全失效**
|
||||
- **违反规则**:项目规则 "Provider 是否正确注入数据服务"
|
||||
- **后果**:筛选 UI 仍可见但点击无反应,用户体验差
|
||||
|
||||
#### 问题 10:`publish-service.ts` 多步写操作未包裹事务
|
||||
|
||||
- **位置**:`publish-service.ts` 第 56-197 行
|
||||
- **问题**:`publishLessonPlanHomework` 包含 5 个写操作步骤(创建题目、创建 exam 草稿、插入 exam 题目关联、下发作业、回写溯源标记),**均未包裹在 `db.transaction` 中**
|
||||
- **违反规则**:项目规则 "需要原子性的操作必须包裹在 db.transaction 中"
|
||||
- **后果**:任一步骤失败将导致数据不一致(孤儿题目、草稿残留、溯源断裂)
|
||||
|
||||
### 2.3 类型安全问题(P1)
|
||||
|
||||
#### 问题 11:`rf-mappers.ts` 颜色映射 Bug
|
||||
|
||||
- **位置**:`rf-mappers.ts` 第 113 行
|
||||
- **问题**:`getNodeColor(anchor.nodeId)` 传入 nodeId 而非 node type,`getNodeColor` 期望接收节点类型(如 `"objective"`),传入 nodeId 必然找不到匹配项
|
||||
- **违反规则**:功能性 Bug
|
||||
- **后果**:锚点边始终使用默认灰色 `#9e9e9e`,注释声称的"P1-4 修复"实际未生效
|
||||
|
||||
#### 问题 12:富文本类型配置三处不一致
|
||||
|
||||
- **位置**:`constants.ts` 第 25-36 行、`block-registry.tsx` 第 50、56-69 行
|
||||
- **问题**:`RICH_TEXT_BLOCK_TYPES`(10项)vs `RICH_TEXT_TYPES`(2项)vs `BLOCK_REGISTRY.isRichText`(2项)三处定义语义冲突
|
||||
- **违反规则**:配置一致性
|
||||
- **后果**:`isRichTextBlock()` 与 `RICH_TEXT_BLOCK_TYPES` 行为完全相反,调用方混用会产生矛盾结果
|
||||
|
||||
#### 问题 13:多处 `as` 断言违规
|
||||
|
||||
- **位置**:
|
||||
- `node-summary.ts` 第 28-55 行:`as { html?: string; ... }` 将联合类型转为内联接口
|
||||
- `rf-mappers.ts` 第 81、95 行:`as Record<string, unknown>`
|
||||
- `block-renderer.tsx` 第 107、115、121、125 行:4 处 `as XxxBlockData`
|
||||
- `lesson-plan-readonly-view.tsx` 第 91 行:`as Edge[]`
|
||||
- `inline-question-editor.tsx` 第 34 行:`as typeof QUESTION_TYPES[number]`
|
||||
- `data-access-knowledge.ts` 第 24、39、67、82 行:4 处 `as` 断言
|
||||
- `use-lesson-plan-editor.ts` 第 137、138、154、155 行:4 处 `as` 断言
|
||||
- 4 个页面文件中 `findChapter` 的 `as typeof chapters`(冗余断言)
|
||||
- `actions.ts` 第 144 行:`as unknown as LessonPlanDocument` 双重断言
|
||||
- **违反规则**:项目规则 "禁止 `as` 断言(除类型收窄外)"
|
||||
- **后果**:类型安全被绕过,潜在运行时错误
|
||||
|
||||
#### 问题 14:`actions-ai.ts`/`actions-kp.ts` 隐式 any
|
||||
|
||||
- **位置**:`actions-ai.ts` 第 34 行 `Array.isArray(doc.nodes)` 收窄为 `any[]`;`actions-kp.ts` 第 30 行 `let kps;` 隐式 any
|
||||
- **违反规则**:项目规则 "禁止 `any`"
|
||||
- **后果**:类型安全缺失
|
||||
|
||||
### 2.4 i18n 与错误处理问题(P1)
|
||||
|
||||
#### 问题 15:`actions-ai.ts`/`actions-kp.ts` 未使用 `handleActionError` 和 `translateFieldErrors`
|
||||
|
||||
- **位置**:`actions-ai.ts` 第 23、41-45 行;`actions-kp.ts` 第 23、42-46 行
|
||||
- **问题**:Zod 错误未调用 `translateFieldErrors()` 翻译;catch 块未使用 `handleActionError()`,非权限错误被静默吞掉,无日志
|
||||
- **违反规则**:项目规则 "所有 Server Action catch 块改用 handleActionError"
|
||||
- **后果**:错误不可观测,用户看到未翻译的 i18n 键
|
||||
|
||||
#### 问题 16:`block-renderer.tsx` 硬编码中文
|
||||
|
||||
- **位置**:`block-renderer.tsx` 第 130 行
|
||||
- **问题**:`"未知 block 类型"` 直接硬编码为中文,整个文件未 import `useTranslations`
|
||||
- **违反规则**:项目规则 "所有用户可见文本必须适配 i18n"
|
||||
- **后果**:多语言环境下显示中文
|
||||
|
||||
#### 问题 17:`schema.ts` 多个 schema 缺少 i18n 错误消息
|
||||
|
||||
- **位置**:`schema.ts` 第 14-32 行
|
||||
- **问题**:仅 `createLessonPlanSchema` 和 `publishLessonPlanHomeworkSchema` 部分字段使用 i18n 键,其他 6 个 schema 均未使用
|
||||
- **违反规则**:项目规则 "Zod schema 错误消息应使用 i18n 键"
|
||||
- **后果**:Zod 校验错误返回英文默认消息
|
||||
|
||||
### 2.5 a11y 与 UX 问题(P1/P2)
|
||||
|
||||
#### 问题 18:`inline-question-editor.tsx` Modal 缺少焦点陷阱
|
||||
|
||||
- **位置**:`inline-question-editor.tsx` 第 72-227 行
|
||||
- **问题**:模态框设置了 `role="dialog"` 和 `aria-modal="true"`,但未实现焦点陷阱、Escape 关闭、焦点管理
|
||||
- **违反规则**:a11y 键盘导航
|
||||
- **后果**:键盘用户无法正常使用模态框
|
||||
|
||||
#### 问题 19:多处缺少 aria-label
|
||||
|
||||
- **位置**:`lesson-plan-editor.tsx` 第 239-243 行(标题输入框)、第 330-335 行(添加节点按钮);`block-renderer.tsx` 第 71-102 行(4 个图标按钮);`lesson-plan-readonly-view.tsx` 第 89-103 行(画布缺少 aria)
|
||||
- **违反规则**:a11y 语义化标签
|
||||
- **后果**:屏幕阅读器无法识别元素用途
|
||||
|
||||
#### 问题 20:`getLessonPlanStats` 的 archived 恒为 0
|
||||
|
||||
- **位置**:`data-access.ts` 第 553-566 行、`admin/lesson-plans/page.tsx` 第 63-72 行
|
||||
- **问题**:WHERE 子句排除 archived 后硬编码返回 `archived: 0`,但 admin 页面仍渲染"已归档"统计卡片
|
||||
- **违反规则**:数据准确性
|
||||
- **后果**:统计卡片永远显示 0,具有误导性
|
||||
|
||||
### 2.6 性能与代码质量问题(P2)
|
||||
|
||||
#### 问题 21:`use-lesson-plan-editor.ts` 303 行超标
|
||||
|
||||
- **位置**:`hooks/use-lesson-plan-editor.ts`
|
||||
- **问题**:文件 303 行,远超 Hook 80 行建议上限
|
||||
- **违反规则**:项目规则 "自定义 Hook:建议 ≤ 80 行"
|
||||
- **后果**:可维护性差
|
||||
|
||||
#### 问题 22:`findChapter` 在 4 个页面重复
|
||||
|
||||
- **位置**:teacher edit、admin view、student view、parent view
|
||||
- **问题**:4 个页面文件中存在完全相同的 `findChapter` 递归函数实现
|
||||
- **违反规则**:DRY 原则
|
||||
- **后果**:代码重复,维护成本高
|
||||
|
||||
#### 问题 23:`lesson-plan-card.tsx` 4 个异步函数未使用 useCallback
|
||||
|
||||
- **位置**:`lesson-plan-card.tsx` 第 72、89、105、122 行
|
||||
- **问题**:`handleArchive`/`handleDuplicate`/`handlePublish`/`handleUnpublish` 每次渲染重新创建
|
||||
- **违反规则**:性能最佳实践
|
||||
- **后果**:不必要渲染
|
||||
|
||||
#### 问题 24:多处 Tailwind 任意值
|
||||
|
||||
- **位置**:`lesson-plan-editor.tsx`(4处)、`node-edit-panel.tsx`(2处)、`inline-question-editor.tsx`(3处)
|
||||
- **违反规则**:项目规则 "禁止使用任意值(`w-[137px]`),除非有充分理由并注释"
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与优秀 K12 产品的差距
|
||||
|
||||
| 维度 | 优秀实践 | 当前实现 | 差距影响 |
|
||||
|------|----------|----------|----------|
|
||||
| **权限隔离** | 家长仅能查看自己孩子班级的课案,学生仅能查看自己班级课案 | 家长/学生可查看全校已发布课案 | 信息泄露风险,违反 FERPA/GDPR 等隐私法规 |
|
||||
| **数据一致性** | 发布作业等关键操作使用事务保证原子性 | 多步写操作无事务 | 部分失败导致数据不一致,需要人工修复 |
|
||||
| **版本管理** | 版本创建/删除有严格的归属校验 | 版本创建/删除可越权操作 | 用户可篡改他人课案历史 |
|
||||
| **a11y** | 模态框实现完整焦点管理,所有交互元素有 aria-label | Modal 无焦点陷阱,多处缺少 aria-label | 残障用户无法使用 |
|
||||
| **错误可观测性** | 所有异常记录日志,有统一错误处理 | 部分Action 静默吞掉错误 | 生产问题无法排查 |
|
||||
| **配置一致性** | 单一数据源定义 Block 类型属性 | 三处富文本配置冲突 | 运行时行为不可预测 |
|
||||
| **模块解耦** | 通过接口抽象依赖,支持独立测试 | 直接 import AI 模块组件 | 无法独立测试,变更影响扩散 |
|
||||
|
||||
### 3.2 缺失的功能
|
||||
|
||||
- **课案协作**:优秀产品支持多位教师协作编辑同一课案,当前仅支持单教师
|
||||
- **课案评课**:教研组长可对课案添加评课意见,当前缺失
|
||||
- **课案资源库**:跨学期/学年的课案资源库检索,当前仅按教材章节组织
|
||||
- **AI 智能备课**:基于课标自动生成教学目标/重难点,当前仅有知识点推荐
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(严重 — 安全/正确性,必须立即修复)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|----------|
|
||||
| P0-1 | Parent/Student 路由未校验归属关系 | 修复 `buildScopeCondition` 的 `children`/`class_members` 分支,使用 `scope.classIds`/`scope.gradeIds`/`scope.childrenIds` 过滤;修复 `getLessonPlanById` 增加归属校验 |
|
||||
| P0-2 | `createLessonPlanVersion` 未校验 planId 归属 | 事务开头增加 `lessonPlans.creatorId = userId` 校验 |
|
||||
| P0-3 | `pruneAutoVersions` 无 userId 参数 | 增加 `userId` 参数,删除前校验 planId 归属 |
|
||||
| P0-4 | `getLessonPlansByKnowledgePoint`/`getLessonPlansByQuestion` 无权限过滤 | 增加 `userId` 参数,过滤 `status != 'archived'`,按 creatorId/DataScope 限制 |
|
||||
| P0-5 | `saveLessonPlanVersionAction` schema 不含 content | 定义 `lessonPlanDocumentSchema` 递归校验文档结构 |
|
||||
| P0-6 | `publishLessonPlanHomeworkAction` homeworkTitle 传入 planId | 先查询课案标题再传入 |
|
||||
| P0-7 | `getLessonPlansAction` params 未验证 | 定义 Zod schema,data-access 使用 `escapeLikePattern` |
|
||||
| P0-8 | `rf-mappers.ts` 颜色映射 Bug | 传入节点 type 而非 nodeId |
|
||||
| P0-9 | 富文本配置三处不一致 | 统一为单一数据源,删除冗余定义 |
|
||||
| P0-10 | `block-renderer.tsx` 硬编码中文 + as 断言 | 使用 i18n + 类型守卫 |
|
||||
| P0-11 | `node-edit-panel.tsx` 直接 import @/modules/ai | 通过 props 注入 AI 组件 |
|
||||
| P0-12 | `publish-service.ts` 多步写操作无事务 | 包裹 `db.transaction` |
|
||||
| P0-13 | admin/student/parent 列表页未包裹 Provider | 包裹 `LessonPlanProviderSetup` |
|
||||
|
||||
### P1(高 — 类型安全/规范一致性)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|----------|
|
||||
| P1-1 | 多处 `as` 断言(node-summary/rf-mappers/block-renderer/readonly-view/inline-question-editor/data-access-knowledge/use-lesson-plan-editor/4页面) | 使用类型守卫替代 |
|
||||
| P1-2 | `actions-ai.ts`/`actions-kp.ts` 隐式 any | 显式类型标注 |
|
||||
| P1-3 | `actions-ai.ts`/`actions-kp.ts` 未用 handleActionError/translateFieldErrors | 统一错误处理 |
|
||||
| P1-4 | `schema.ts` 多个 schema 缺 i18n 错误消息 | 补全 i18n 键 |
|
||||
| P1-5 | `getLessonPlanStats` archived 恒为 0 | 修复统计逻辑 |
|
||||
| P1-6 | `findChapter` 4 处重复 | 提取为 textbooks 共享工具 |
|
||||
| P1-7 | `inline-question-editor.tsx` Modal 缺焦点陷阱 | 实现焦点管理 |
|
||||
| P1-8 | 多处缺少 aria-label | 补全 a11y 属性 |
|
||||
| P1-9 | `data-access-knowledge.ts` LIKE 未用 escapeLikePattern | 统一转义 |
|
||||
| P1-10 | `actions-publish.ts` 重复 requirePermission | 复用 AuthContext |
|
||||
|
||||
### P2(中 — 性能/代码质量)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|----------|
|
||||
| P2-1 | `use-lesson-plan-editor.ts` 303 行超标 | 拆分为多个 slice |
|
||||
| P2-2 | `lesson-plan-card.tsx` 4 函数未 useCallback | 包裹 useCallback |
|
||||
| P2-3 | 多处 Tailwind 任意值 | 提取设计令牌 |
|
||||
| P2-4 | `lesson-plan-list.tsx` 缺筛选加载状态 | 增加 isLoading |
|
||||
| P2-5 | `block-renderer.tsx` 废弃文件 | 评估是否可删除 |
|
||||
| P2-6 | `ai-suggest.ts` prompt 硬编码中文 | 参数化注入 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图需补充以下内容:
|
||||
|
||||
1. **依赖关系补充**:`node-edit-panel.tsx` 对 `@/modules/ai` 的直接依赖需在 `005_architecture_data.json` 的 `lesson_preparation.dependsOn` 中记录
|
||||
2. **auditFixes 补充**:本次 V3 续续审计的修复记录(V3-23 ~ V3-35)需添加到 `005_architecture_data.json` 的 `auditFixes` 对象
|
||||
3. **文件清单更新**:若 `use-lesson-plan-editor.ts` 拆分为多个 slice,需更新 `004` 文件清单和 `005` files 数组
|
||||
4. **配置冲突修复**:`constants.ts` 与 `block-registry.tsx` 的富文本配置统一后,需更新 `004` 中两者的描述
|
||||
|
||||
---
|
||||
|
||||
## 六、实施计划
|
||||
|
||||
本报告所有问题将按 P0 → P1 → P2 顺序完整实施,实施过程中同步更新架构图 004/005。
|
||||
704
docs/architecture/audit/lesson-preparation-audit-report-v4.md
Normal file
704
docs/architecture/audit/lesson-preparation-audit-report-v4.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# 备课模块审计报告 v4
|
||||
|
||||
> 审计日期:2026-06-25
|
||||
> 审计范围:`src/modules/lesson-preparation/` 全部 45 个文件 + `src/app/(dashboard)/{teacher,admin,student,parent}/lesson-plans/` 共 27 个路由文件
|
||||
> 审计依据:`e:\Desktop\CICD\.trae\rules\project_rules.md`、`docs/architecture/004_architecture_impact_map.md` §2.27、`docs/architecture/005_architecture_data.json` modules.lesson_preparation
|
||||
> 前序报告:`lesson-preparation-audit-report-v3.md`(2026-06-24)
|
||||
> 审计方法:三路并行子代理深度代码审计(actions/data-access 层 + 组件/hooks/lib 层 + app 路由/i18n 层)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布(45 + 27 = 72 个文件)
|
||||
|
||||
| 层级 | 文件数 | 主要文件(行数实测) |
|
||||
|------|--------|----------------------|
|
||||
| types/schema/constants | 3 | types.ts(345) / schema.ts(81) / constants.ts(106) |
|
||||
| data-access | 4 | data-access.ts(606) / data-access-versions.ts(201) / data-access-templates.ts(130) / data-access-knowledge.ts(138) |
|
||||
| actions | 4 | actions.ts(401) / actions-publish.ts(89) / actions-ai.ts(47) / actions-kp.ts(51) |
|
||||
| services | 3 | publish-service.ts(222) / ai-suggest.ts(84) / services/default-data-service.ts(165) |
|
||||
| lib | 6 | type-guards.ts(241) / i18n-errors.ts(50) / document-migration.ts(330) / anchor-injector.ts(304) / node-summary.ts(136) / rf-mappers.ts(186) |
|
||||
| config | 1 | block-registry.tsx(198) |
|
||||
| providers | 2 | lesson-plan-provider.tsx(338) / lesson-plan-provider-setup.tsx(29) |
|
||||
| hooks | 1 | use-lesson-plan-editor.ts(319) |
|
||||
| components | 21 | 7 业务组件 + 4 nodes + 11 blocks 中 + block-renderer.tsx(@deprecated) |
|
||||
| seed | 1 | seed-templates.ts(9) |
|
||||
| **app 路由** | 27 | 9 个 page.tsx + 9 loading.tsx + 9 error.tsx + 1 slot.tsx |
|
||||
|
||||
**行数合规性**:所有文件均 ≤ 800 行(actions/data-access 上限),≤ 500 行(组件上限),未触发 1000 行硬性上限。
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
app/(dashboard)/{role}/lesson-plans/page.tsx (Server Component)
|
||||
├─ getAuthContext() → data-access.getLessonPlans({}, scope, userId) → DB
|
||||
└─ getTranslations("lessonPreparation") → 渲染
|
||||
↓
|
||||
LessonPlanProviderSetup (Client Component 包装)
|
||||
└─ LessonPlanProvider (Context 注入 service + roleConfig + tracker)
|
||||
↓
|
||||
LessonPlanList / LessonPlanCard / LessonPlanEditor
|
||||
└─ useLessonPlanContextSafe().service.getLessonPlans(...)
|
||||
↓
|
||||
default-data-service → Server Actions
|
||||
└─ requirePermission() → data-access → DB
|
||||
```
|
||||
|
||||
### 1.3 架构图完整性核对
|
||||
|
||||
- `docs/architecture/004_architecture_impact_map.md` §2.27 记录的导出函数、文件清单、依赖关系与实际代码**完全一致**
|
||||
- `docs/architecture/005_architecture_data.json` modules.lesson_preparation.auditFixes 已记录 P0-1 至 V3-46 共 60+ 项修复
|
||||
- **V3 已落地修复**:跨模块直查(P0-1)、i18n 接入(P0-2)、DataScope 过滤(P0-3)、`as` 断言清零(V2-3/V3-17/V3-22)、组件完全通过 service 调用(V3-16/V3-20)、a11y + 类型守卫(V3-7/V3-8)、findChapterById 共享(V3-42)、`useCallback` 性能优化(V3-44)等
|
||||
- **本审计未发现新的架构图遗漏节点**,但建议在 auditFixes 增补 v4 新增条目(详见第五章)
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 安全与权限问题(P0)
|
||||
|
||||
#### 问题 1:Parent/Student 只读路由未校验 `gradeId` 跨年级归属 — 信息泄露漏洞
|
||||
|
||||
- **位置**:`src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx` L21-31、`src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx` L21-41
|
||||
- **问题**:两个路由仅校验 `plan.status === "published"`,**完全未校验 `plan.gradeId` 是否在 `ctx.dataScope.gradeIds` 范围内**
|
||||
- **直接后果**:任何家长/学生通过修改 URL 中的 `planId`(仅校验 published),即可查看**其他年级**已发布课案
|
||||
- **数据访问层明示警示**:[data-access.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts) L323-326 注释明确写道:
|
||||
|
||||
> 安全说明:此函数仅校验 creator 或 published 状态。对于 parent/student 角色,调用方(页面层)必须额外校验 plan.gradeId 是否在 ctx.dataScope.gradeIds 范围内,防止跨年级信息泄露。
|
||||
|
||||
**页面层未补齐该校验**,形成显式的"已知漏洞"
|
||||
- **违反规则**:项目记忆"Parent routes must include permission checks with both parentId and studentId to prevent information leakage"
|
||||
|
||||
#### 问题 2:9 个 lesson-plans 页面层全部缺失 `requirePermission()` 调用
|
||||
|
||||
- **位置**:所有 `app/(dashboard)/{teacher,admin,student,parent}/lesson-plans/**/page.tsx`
|
||||
- **问题**:全部 9 个页面仅调用 `getAuthContext()` 获取上下文,**未调用 `requirePermission(Permissions.LESSON_PLAN_READ)`**
|
||||
- **对比**:本仓库 `app/(dashboard)/{announcements,attendance,messages,textbooks,exams,...}` 等 100+ 处页面均显式调用 `requirePermission()`,本模块属唯一例外
|
||||
- **直接后果**:依赖 data-access 层 scope 过滤兜底;如未来 data-access 出现 scope 缺失(如新增查询函数忘记加 scope),页面层无二次防线
|
||||
- **违反规则**:项目规则 "All Server Actions must call `requirePermission()` for permission verification" + "Frontend permission checks must use `usePermission().hasPermission()`"
|
||||
|
||||
#### 问题 3:`textbook-segments.tsx` 锚点段落键盘用户完全不可操作 — a11y 严重缺陷
|
||||
|
||||
- **位置**:[textbook-segments.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/nodes/textbook-segments.tsx) L47、L76
|
||||
- **问题**:锚点 span 仅绑定 `onClick`,**无 `role="button"` / `tabIndex={0}` / `onKeyDown`**
|
||||
- **直接后果**:键盘用户、读屏器用户完全无法触发锚点跳转/高亮,违反 WCAG 2.1 AA 的 2.1.1 键盘可访问性
|
||||
- **违反规则**:项目规则 "a11y:语义化标签、ARIA 属性、键盘导航"
|
||||
|
||||
### 2.2 架构与解耦问题(P0/P1)
|
||||
|
||||
#### 问题 4:`default-data-service.ts` 直接 import 其他业务模块的 actions
|
||||
|
||||
- **位置**:[default-data-service.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/services/default-data-service.ts) L21
|
||||
```typescript
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
```
|
||||
- **问题**:项目规则要求"模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)"。虽收口在 service 实现层而非组件直接 import,但**违反了"完全解耦"原则**——本模块对 `questions/actions` 形成编译期硬依赖
|
||||
- **直接后果**:`questions` 模块 actions 签名变更会破坏本模块编译;无法在不引入 questions 模块的情况下独立测试本模块
|
||||
- **违反规则**:审计任务要求 "完全解耦:模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)"
|
||||
|
||||
#### 问题 5:4 个对话框/抽屉缺失 FocusTrap — 焦点陷阱不完整
|
||||
|
||||
- **位置**:
|
||||
- [publish-homework-dialog.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/publish-homework-dialog.tsx) 全文
|
||||
- [question-bank-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/question-bank-picker.tsx) L117-119
|
||||
- [knowledge-point-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/knowledge-point-picker.tsx) L77-79
|
||||
- [version-history-drawer.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx) L87-89
|
||||
- **问题**:4 个组件均使用 `role="dialog"` + `aria-modal="true"`,但**未使用 FocusTrap 包裹**。仅 `inline-question-editor.tsx` L82 正确使用了 `<FocusTrap>`
|
||||
- **直接后果**:Tab 键焦点可逃逸到对话框背后的页面,违反 WAI-ARIA Dialog 模式
|
||||
- **违反规则**:项目规则 "a11y:键盘导航"
|
||||
|
||||
#### 问题 6:4 个对话框/抽屉 ESC 键关闭未实现
|
||||
|
||||
- **位置**:同问题 5 的 4 个组件
|
||||
- **问题**:仅有遮罩点击关闭 (`onClick={onClose}`),未监听 `keydown` ESC 键
|
||||
- **直接后果**:用户只能点击关闭按钮,无键盘快捷关闭路径
|
||||
- **违反规则**:项目规则 "a11y:键盘导航"
|
||||
|
||||
#### 问题 7:未使用 React Suspense + 骨架屏(项目规则明确要求)
|
||||
|
||||
- **位置**:全模块客户端组件
|
||||
- **问题**:项目规则要求"异步数据使用 React Suspense + 骨架屏"。本模块在 `lesson-plan-skeleton.tsx` 定义了 4 个骨架屏组件(`VersionListSkeleton`/`QuestionBankSkeleton`/`KnowledgePointSkeleton`/`LessonPlanListSkeleton`),但**这些骨架屏仅被 `loading.tsx` 文件使用(路由级 Suspense),未在客户端组件数据加载时使用**
|
||||
- **现状**:客户端组件用本地 `isLoading` state + 文本"加载中..."代替骨架屏
|
||||
- **直接后果**:组件级异步加载(如打开 version-history-drawer)显示纯文本而非骨架,UX 不一致
|
||||
- **违反规则**:项目规则 "异步数据使用 React Suspense + 骨架屏"
|
||||
|
||||
#### 问题 8:Error Boundary 覆盖不全
|
||||
|
||||
- **位置**:
|
||||
- [lesson-plan-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx) — NodeEditor 画布未被 Error Boundary 包裹(仅 NodeEditPanel 内的 BlockRenderer 被包裹)
|
||||
- [version-history-drawer.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx) 全文未包裹
|
||||
- [publish-homework-dialog.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/publish-homework-dialog.tsx) 全文未包裹
|
||||
- [knowledge-point-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/knowledge-point-picker.tsx) 全文未包裹
|
||||
- [question-bank-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/question-bank-picker.tsx) 全文未包裹
|
||||
- **问题**:项目规则要求"每个独立的数据区块必须用 React Error Boundary 包裹"。当前仅 `node-edit-panel.tsx` L161-173 包裹 `BlockRenderer`,其他独立数据区块无兜底
|
||||
- **直接后果**:单个 Picker 数据加载失败会冒泡到路由级 `error.tsx`,整个页面变白屏
|
||||
- **违反规则**:项目规则 "每个独立的数据区块必须用 React Error Boundary 包裹"
|
||||
|
||||
### 2.3 类型安全与代码质量(P1/P2)
|
||||
|
||||
#### 问题 9:`i18n-errors.ts` L24 无注释的 `as` 断言
|
||||
|
||||
- **位置**:[i18n-errors.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/i18n-errors.ts) L24
|
||||
```typescript
|
||||
t(msg as Parameters<typeof t>[0])
|
||||
```
|
||||
- **问题**:从 `string` 收窄到 `next-intl` 的 i18n key 联合类型,无注释说明
|
||||
- **违反规则**:项目规则 "禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)"
|
||||
|
||||
#### 问题 10:硬编码颜色字符串散落多处,未使用 CSS 变量
|
||||
|
||||
- **位置**:
|
||||
- [node-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx) L228 `stroke: "#1976d2"`、L237 `color="#ccc"`
|
||||
- [lesson-node.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/nodes/lesson-node.tsx) L43 `borderColor: selected ? "#1976d2" : color`、L44 `boxShadow` rgba
|
||||
- [textbook-content-node.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx) L319 `borderColor: selected ? "#1976d2" : "#455a64"`、L329 `backgroundColor: "#455a64"`、L386 `backgroundColor: "#1976d2"`
|
||||
- [node-summary.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/node-summary.ts) L118-132 `NODE_COLORS` 13 个 hex 颜色
|
||||
- **问题**:V3 已修复 MiniMap 颜色硬编码(V3-18),但节点本身、画布背景、NODE_COLORS 仍硬编码
|
||||
- **违反规则**:项目规则 "设计令牌在 src/app/globals.css 中使用 CSS 变量定义"
|
||||
|
||||
#### 问题 11:`exercise-block.tsx` 使用原生 `<a>` 标签
|
||||
|
||||
- **位置**:[exercise-block.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx) L123-128
|
||||
```tsx
|
||||
<a href="/teacher/homework">...</a>
|
||||
```
|
||||
- **问题**:未使用 Next.js `<Link>`,导致整页刷新
|
||||
- **违反规则**:项目记忆 "Link navigation must use Next.js `<Link>` component instead of raw `<a>` tags"
|
||||
|
||||
#### 问题 12:6 个 block 组件使用 `key={idx}` 列表 key
|
||||
|
||||
- **位置**:`homework-block.tsx` L46 / `key-point-block.tsx` L46 / `new-teaching-block.tsx` L51 / `objective-block.tsx` L49 / `reflection-block.tsx` L46 / `summary-block.tsx` L41
|
||||
- **问题**:列表项增删时 React key 不稳定,可能引发状态错乱
|
||||
- **违反规则**:React key 稳定性最佳实践(隐含在"代码质量规则"中)
|
||||
|
||||
#### 问题 13:`template-picker.tsx` 字符串拼接动态类名
|
||||
|
||||
- **位置**:[template-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/template-picker.tsx) L265-269、L296-300
|
||||
```tsx
|
||||
className={`... ${selected === tpl.id ? "border-primary ..." : "border-transparent ..."}`}
|
||||
```
|
||||
- **问题**:违反"禁止字符串拼接动态类名"规则(应使用 `cn()`)
|
||||
- **违反规则**:项目规则 "禁止字符串拼接动态类名(`bg-${color}-500`)" + "使用 `cn()` 工具函数管理条件类名"
|
||||
- **波及范围**:本模块全部 21 个组件均未使用 `cn()`,全部用模板字符串拼接,但 `template-picker.tsx` 的条件分支最复杂
|
||||
|
||||
#### 问题 14:`block-registry.tsx` 缺失 `"use client"` 指令
|
||||
|
||||
- **位置**:[block-registry.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/config/block-registry.tsx) L1
|
||||
- **问题**:`BlockRenderer` 函数(L88)返回 ReactElement 并 switch 渲染各 client block 组件,本文件应明确标注 `"use client"`
|
||||
- **违反规则**:项目规则 "需要交互时才添加 `\"use client\"`(必须位于文件第一行)"
|
||||
|
||||
#### 问题 15:`NodeEditPanel.knownTypes` 与 `BLOCK_REGISTRY` 重复定义(违反 DRY)
|
||||
|
||||
- **位置**:[node-edit-panel.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-edit-panel.tsx) L241
|
||||
- **问题**:本地维护 `knownTypes` 数组与 `BLOCK_REGISTRY` 的 keys 完全重复
|
||||
- **违反规则**:项目规则隐含的 DRY 原则
|
||||
|
||||
#### 问题 16:`inline-question-editor.tsx` 的 `QUESTION_TYPES` 与 `type-guards.VALID_QUESTION_TYPES` 重复
|
||||
|
||||
- **位置**:[inline-question-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx) L21、[type-guards.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/type-guards.ts) L184
|
||||
- **问题**:`inline-question-editor` 仅定义 3 种题型子集,`type-guards` 已定义完整 5 种,应复用并 filter
|
||||
|
||||
#### 问题 17:`ROLE_CONFIGS` 缺失 `gradeHead` 角色,与 `LessonPlanCard.viewMode` 不一致
|
||||
|
||||
- **位置**:[lesson-plan-provider.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx) L255-260 `ROLE_CONFIGS` vs [lesson-plan-card.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-card.tsx) L50 `viewMode` 类型包含 `"gradeHead"`
|
||||
- **问题**:组件类型支持 gradeHead 视图,但 Provider 配置表无对应配置
|
||||
- **直接后果**:gradeHead 用户访问时会静默回退到 TEACHER_ROLE_CONFIG,可能误显示教师操作按钮
|
||||
|
||||
#### 问题 18:`useRoleConfig` 静默回退教师配置,无开发环境告警
|
||||
|
||||
- **位置**:[lesson-plan-provider.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/providers/lesson-plan-provider.tsx) L319-322
|
||||
- **问题**:Provider 外调用 `useRoleConfig()` 时静默回退到 `TEACHER_ROLE_CONFIG`,无 console.warn
|
||||
- **直接后果**:开发者误用时不报警,可能在生产环境暴露教师操作按钮给非教师角色
|
||||
|
||||
### 2.4 性能与代码组织(P2)
|
||||
|
||||
#### 问题 19:3 个组件用 `Promise.resolve().then()` / `queueMicrotask` 规避同步 setState
|
||||
|
||||
- **位置**:
|
||||
- [knowledge-point-picker.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/knowledge-point-picker.tsx) L33-65
|
||||
- [version-history-drawer.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx) L46
|
||||
- **问题**:可读性差,应使用 `useEffect` 内 async + ignore flag 模式
|
||||
|
||||
#### 问题 20:3 个 view 页 textbook/chapters 串行查询,可并行化
|
||||
|
||||
- **位置**:`parent/lesson-plans/[planId]/view/page.tsx`、`student/lesson-plans/[planId]/view/page.tsx`、`admin/lesson-plans/[planId]/view/page.tsx` L37-46
|
||||
- **问题**:textbook + chapters 查询彼此独立(依赖均为 plan.textbookId),但代码串行 await
|
||||
- **违反规则**:项目记忆 "Data fetching for parent dashboard should use `Promise.all` or `Promise.allSettled` for parallel queries"
|
||||
|
||||
#### 问题 21:3 个 view 页错误处理不一致
|
||||
|
||||
- **位置**:admin/view 用 `notFound()` 抛 404;parent/view 与 student/view 用 inline `<div>` 返回 200 + 错误文案
|
||||
- **问题**:行为分歧,应统一为 `notFound()` 或统一的 `InlineLessonPlanError` 组件
|
||||
|
||||
#### 问题 22:`block-renderer.tsx` 已 @deprecated 但仍存在
|
||||
|
||||
- **位置**:[block-renderer.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/block-renderer.tsx) L3-6 注释
|
||||
- **问题**:已被 `NodeEditor` 替代,但保留 195 行废弃代码
|
||||
- **直接后果**:维护负担、可能被误用
|
||||
|
||||
#### 问题 23:`anchor-injector.ts` 的 `skipPatterns` 与 `markdownToPlainText` 逻辑不一致
|
||||
|
||||
- **位置**:[anchor-injector.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/anchor-injector.ts) L117-123 vs L26-51
|
||||
- **问题**:前者用 `^` 多行匹配,后者用 `gm`,可能导致偏移映射与纯文本转换结果不一致,锚点定位偏差
|
||||
|
||||
#### 问题 24:`use-lesson-plan-editor.ts` Zustand store 319 行,超出 Hook ≤80 行建议
|
||||
|
||||
- **位置**:[use-lesson-plan-editor.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts)
|
||||
- **问题**:项目规则原文 "自定义 Hook ≤ 80 行"。虽然 Zustand store 性质与 React hook 不同(集中定义 state + actions),但仍建议拆分为多个 slice
|
||||
- **违反规则**:项目规则 "自定义 Hook:建议 ≤ 80 行"
|
||||
|
||||
#### 问题 25:`actions.ts` L187 返回原始字符串而非 i18n key
|
||||
|
||||
- **位置**:[actions.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions.ts) L187
|
||||
```typescript
|
||||
return { success: false, message: "PLAN_NOT_FOUND" }
|
||||
```
|
||||
- **问题**:与文件其余部分使用 `t("error.xxx")` 风格不一致
|
||||
|
||||
#### 问题 26:data-access-templates.ts 与 data-access.ts 类型守卫重复定义
|
||||
|
||||
- **位置**:[data-access-templates.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-templates.ts) L19-50 与 [lib/type-guards.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/type-guards.ts) L30-49 完全重复
|
||||
- **问题**:`isTemplateType`/`isTemplateScope`/`mapRowToTemplate` 在两个文件 + lib/type-guards.ts 三处定义
|
||||
- **违反规则**:DRY 原则
|
||||
|
||||
#### 问题 27:data-access-knowledge.ts 两处 `.map(r => ({ ... }))` 完全重复
|
||||
|
||||
- **位置**:[data-access-knowledge.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-knowledge.ts) L63-86 与 L114-137(各 24 行完全相同)
|
||||
- **问题**:应提取为共享辅助函数
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
基于 K12 教育信息化主流产品对标(Planboard / Chalk.com、Nearpod、PlanbookEdu、Common Curriculum、SMART Learning Suite、Google Classroom、Microsoft Teams + OneNote Class Notebook 等),本模块相对行业优秀实践的差距如下:
|
||||
|
||||
### 3.1 课程标准对标缺失(严重差距)
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Planboard/Common Curriculum 强制要求每节课关联**州/国家标准**(如 Common Core、NGSS、TEKS) | 本模块仅支持知识点标注,无课程标准(Standards)维度 | 教研组无法横向追踪课标覆盖度,无法生"课标覆盖报告";管理员无法看到全校课标达成情况 |
|
||||
| 行业普遍支持**课标搜索**与一键关联到课案 | 本模块知识点来自 textbook 章节自定义树,无标准课标库 | 跨教材、跨学段的课标追溯断链 |
|
||||
|
||||
### 3.2 协同备课完全缺失(严重差距)
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Planboard/Google Docs 支持**实时多人协同编辑**(OT/CRDT 算法) | 本模块仅支持单人编辑,他人只能只读查看 | 教研组无法共同备课,新教师无法被老教师"带教"修改 |
|
||||
| OneNote Class Notebook 支持**评论批注**(评论挂在 Block 上) | 本模块无评论能力 | 备课反馈只能线下进行,无法沉淀为知识资产 |
|
||||
| Common Curriculum 支持**协作工作流**(提交审核 → 反馈 → 定稿) | 本模块有 `publish/unpublish` 但无审核工作流 | 教研组长无法对备课质量把关 |
|
||||
|
||||
### 3.3 课程地图(Curriculum Mapping)缺失
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Planboard/Atlas 支持**垂直对齐**(vertical alignment):跨年级查看某课标覆盖情况 | 本模块仅按 textbook+chapter 切分,无跨年级视图 | 学段衔接断链风险(小学升初中知识断层) |
|
||||
| 行业支持**Pacing Guide**(进度指南):按周/月对齐课标进度 | 本模块无进度概念 | 教学进度失控,无法判断是否落后于计划 |
|
||||
|
||||
### 3.4 形成性评价闭环不完整
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Nearpod/SMART 支持**课中实时互动**(投票、Exit Ticket、快速问答) | 本模块有 publish-homework 课后作业,无课中互动 | 教师无法在备课时设计嵌入式互动,无法实时获取学情反馈 |
|
||||
| 行业支持**课堂形成性评价结果回写**到课案 | 本模块作业数据与课案无回写关联 | 教师备课迭代无数据支撑,不知道哪些环节学生掌握差 |
|
||||
|
||||
### 3.5 资源管理弱
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| PlanbookEdu 支持**附件库**(视频/PDF/图片/外链)挂载到课案 | 本模块虽有 `files` 模块但未与 lesson-preparation 集成(无 attachment 关联表) | 教师备课需重复上传素材,无统一资源中心 |
|
||||
| 行业支持 **OER(开放教育资源)库**嵌入 | 本模块无 OER 集成 | 教师需手动搜索素材,备课效率低 |
|
||||
|
||||
### 3.6 多角色协同不足
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Planboard 支持**代课教师(substitute teacher)计划** | 本模块无代课机制 | 教师请假时代课教师无法快速接手备课 |
|
||||
| 行业支持**学生互动课件**(不只是只读查看) | 本模块学生只能 LessonPlanReadonlyView 只读查看 | 学生被动接受,无法参与课中互动 |
|
||||
| 行业支持**家长可见备课摘要**(隐私脱敏后) | 本模块家长与学生看到的相同,无差异化摘要 | 家长无法获取"如何辅助孩子预习"的指导 |
|
||||
|
||||
### 3.7 缺失分析能力
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| 行业支持**备课分析仪表盘**:每位教师的备课频次、模板使用率、平均时长 | 本模块仅 admin 页有 4 个统计卡片(total/published/draft/archived) | 学校无法评估教师备课投入,无法识别备课质量低的教师 |
|
||||
| 行业支持**课标覆盖热力图** | 本模块无 | 教研组无法识别薄弱课标 |
|
||||
| 行业支持**版本对比**(diff 视图) | 本模块有版本管理但回滚无 diff 预览 | 教师回滚前无法判断改动幅度 |
|
||||
|
||||
### 3.8 移动端与离线能力
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Planboard 支持**移动端原生 App** | 本模块仅 Web 端,且节点图画布在平板上交互差 | 教师课堂使用不便 |
|
||||
| 行业支持**离线模式** | 本模块无 Service Worker / PWA | 教室网络不稳定时无法备课 |
|
||||
|
||||
### 3.9 AI 增强仍有空间
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| MagicSchool AI / Khanmigo 支持**按学生学情差异化生成**多版本备课 | 本模块 AI 仅生成单版本内容 | 无法应对班级差异化(基础班/提高班) |
|
||||
| 行业支持**AI 自动关联课标** | 本模块 AI 仅推荐知识点 | 课标对标仍需手工 |
|
||||
| 行业支持**AI 评估备课质量**(结构完整性、活动多样性等) | 本模块无 | 教师无备课改进建议 |
|
||||
|
||||
### 3.10 UX 细节差距
|
||||
|
||||
| 行业实践 | 本模块现状 | 影响 |
|
||||
|----------|------------|------|
|
||||
| Planboard 提供**周历/月历视图**拖拽课案 | 本模块仅列表 + 编辑器,无日历 | 教师无法直观看到本周备课分布 |
|
||||
| 行业普遍使用**快捷键体系**(保存 Cmd+S、撤销 Cmd+Z 等) | 本模块节点图编辑器仅基础键盘导航 | 高频操作效率低 |
|
||||
| 行业普遍有**自动保存提示**("已保存 · 2 分钟前") | 本模块编辑器无自动保存状态提示 | 教师不确定是否已保存 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(安全与可用性阻塞,立即修复)
|
||||
|
||||
| # | 问题 | 改进方向(不含实现代码) |
|
||||
|---|------|----------------------|
|
||||
| P0-1 | parent/student view 跨年级信息泄露 | 在 `parent/lesson-plans/[planId]/view/page.tsx` 与 `student/lesson-plans/[planId]/view/page.tsx` 拉取 plan 后追加 `gradeId` scope 校验;建议提取共享校验函数 `assertPlanInScope(plan, ctx)` 在两处复用 |
|
||||
| P0-2 | 9 个页面缺失 `requirePermission()` | 在每个 page.tsx 的 `getAuthContext()` 前调用 `requirePermission(Permissions.LESSON_PLAN_READ)`(admin 可使用更强的 `LESSON_PLAN_MANAGE_ALL` 或就 `LESSON_PLAN_READ`) |
|
||||
| P0-3 | textbook-segments.tsx a11y 严重缺陷 | 锚点 span 添加 `role="button"` + `tabIndex={0}` + `onKeyDown`(Enter/Space 触发 onClick);并补全 `aria-label` |
|
||||
| P0-4 | default-data-service 直接 import questions/actions | 通过 `LessonPlanDataService` 接口注入 `QuestionService`,由 `lesson-plan-provider-setup.tsx` 在 app 层组合 `questions/data-access.getQuestions`(data-access→data-access 合规) |
|
||||
|
||||
### P1(架构与体验,本次审计内修复)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P1-1 | 4 个对话框/抽屉缺失 FocusTrap | 全部包裹 `shared/components/a11y/focus-trap.tsx` 的 `<FocusTrap>`,并补全 ESC 监听 |
|
||||
| P1-2 | 未使用 React Suspense + 骨架屏 | 客户端组件中将 `isLoading` state 改为 React 18 `use(promise)` 或 `<Suspense fallback={<Skeleton/>}>` 模式;消费已定义的 4 个 Skeleton 组件 |
|
||||
| P1-3 | Error Boundary 覆盖不全 | 在 `LessonPlanEditor` 内对 `NodeEditor`、`VersionHistoryDrawer`、各 Picker/Dialog 独立包裹 `LessonPlanErrorBoundary` |
|
||||
| P1-4 | 硬编码颜色 | 提取到 `globals.css` CSS 变量(`--lesson-node-color-objective` 等 11 种),组件用 `var(--...)`;`NODE_COLORS` 改为映射表 |
|
||||
| P1-5 | exercise-block 原生 `<a>` | 替换为 `next/link` 的 `<Link>` |
|
||||
| P1-6 | 6 个 block `key={idx}` | 改为业务唯一 key(如 `homework-${hw.id ?? idx}`) |
|
||||
| P1-7 | template-picker 字符串拼接 className | 引入 `cn()` 替代模板字符串 |
|
||||
| P1-8 | block-registry 缺 "use client" | 文件第一行添加 `"use client"` |
|
||||
| P1-9 | NodeEditPanel.knownTypes 与 BLOCK_REGISTRY 重复 | 改为 `Object.keys(BLOCK_REGISTRY) as BlockType[]` |
|
||||
| P1-10 | inline-question-editor QUESTION_TYPES 重复 | 改为从 `lib/type-guards.ts` 导入 `VALID_QUESTION_TYPES` 并 filter |
|
||||
| P1-11 | ROLE_CONFIGS 缺 gradeHead | 新增 `gradeHead` 角色配置(只读 + 无操作按钮),对齐 viewMode 类型 |
|
||||
| P1-12 | useRoleConfig 静默回退 | 在 `process.env.NODE_ENV === "development"` 下 `console.warn` |
|
||||
| P1-13 | i18n-errors.ts as 断言 | 改用 `t.has(msg) ? t(msg) : msg` 模式或添加注释 |
|
||||
| P1-14 | data-access-templates 重复类型守卫 | 删除本地副本,统一从 `lib/type-guards.ts` 导入 |
|
||||
| P1-15 | data-access-knowledge 重复 .map | 提取共享 `mapRowToListItemWithoutJoin` 辅助函数 |
|
||||
| P1-16 | actions.ts L187 原始字符串 | 改为 `t("error.notFound")` |
|
||||
|
||||
### P2(性能与一致性,本次审计内修复)
|
||||
|
||||
| # | 问题 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| P2-1 | 3 个 view 页 textbook/chapters 串行 | 改为 `Promise.all([getTextbookById, getChaptersByTextbookId])` |
|
||||
| P2-2 | 3 个 view 页错误处理不一致 | 统一为 `notFound()` 调用,或新建 `InlineLessonPlanError` 共享组件 |
|
||||
| P2-3 | Promise.resolve().then() 规避 setState | 改为 `useEffect` 内 async + ignore flag |
|
||||
| P2-4 | block-renderer.tsx @deprecated | 确认无引用后删除 |
|
||||
| P2-5 | anchor-injector skipPatterns 不一致 | 统一 regex 标志为 `gm`,添加单元测试 |
|
||||
| P2-6 | use-lesson-plan-editor Zustand store 拆分 | 拆分为 `editor-slice.ts` + `selection-slice.ts` + `version-slice.ts`,主文件 < 80 行 |
|
||||
|
||||
### 中长期(功能补齐,需独立立项)
|
||||
|
||||
| # | 缺口 | 改进方向 |
|
||||
|---|------|---------|
|
||||
| M1 | 课标(Standards)对标体系 | 新增 `standards` 模块,支持国家标准/课标/自定义三层级;课案关联多对多课标;管理后台提供课标库导入 |
|
||||
| M2 | 协同备课(实时多人编辑 + 评论) | 评估 Yjs/Liveblocks 等方案;评论挂载到 Block 级别(需新表 `lessonPlanComments`) |
|
||||
| M3 | 审核工作流(draft → submitted → approved → published) | 扩展 status 枚举 + 新增 `reviewer` 角色;教研组长审核 |
|
||||
| M4 | 课程地图(Curriculum Map) | 新增 `admin/curriculum-map` 页面,按年级/学科/课标矩阵展示覆盖度 |
|
||||
| M5 | 形成性评价闭环 | publish 时嵌入互动组件(poll/quiz),课中学生作答,结果回写课案 |
|
||||
| M6 | 资源附件库 | 新增 `lessonPlanAttachments` 关联表;UI 提供附件选择器 |
|
||||
| M7 | 移动端 PWA + 离线 | 评估 next-pwa;节点图改为 touch-friendly 模式 |
|
||||
| M8 | AI 差异化生成 + 课标推荐 + 备课质量评估 | 扩展 `ai-suggest.ts`;新增 `evaluateLessonPlan` 函数 |
|
||||
| M9 | 日历视图(周历/月历) | 新增 `teacher/lesson-plans/calendar` 路由,使用全日历组件 |
|
||||
| M10 | 备课分析仪表盘 | 扩展 admin 页,新增"教师备课投入""模板使用率""课标覆盖热力图"3 张图表 |
|
||||
| M11 | 版本 diff 预览 | version-history-drawer 增加"对比当前"按钮,使用 diff-match-patch |
|
||||
| M12 | 代课教师(substitute)机制 | 新增 `lessonPlanSubstitutes` 表与角色映射 |
|
||||
|
||||
---
|
||||
|
||||
## 五、重构方案设计(强制满足全部原则)
|
||||
|
||||
### 5.1 完全解耦:通过接口抽象数据依赖 + Provider 注入
|
||||
|
||||
**目标**:本模块内部组件不直接 import 任何其他业务模块(`questions`、`exams`、`homework`、`classes`、`textbooks`、`ai`、`files`)的 actions 或 data-access,所有跨模块依赖通过 `LessonPlanDataService` 接口的"方法契约"定义,由 `lesson-plan-provider-setup.tsx` 在 app 层注入具体实现。
|
||||
|
||||
**接口契约扩展(`lesson-plan-provider.tsx`)**:
|
||||
|
||||
```typescript
|
||||
// 新增对外部模块的抽象接口
|
||||
export interface QuestionService {
|
||||
getQuestions(params: QuestionPickerParams): Promise<ActionState<{ items: QuestionPickerItem[]; total: number }>>
|
||||
createQuestion(input: unknown): Promise<ActionState<{ questionId: string }>>
|
||||
}
|
||||
|
||||
export interface HomeworkService {
|
||||
createHomeworkAssignment(input: unknown): Promise<ActionState<{ assignmentId: string }>>
|
||||
}
|
||||
|
||||
export interface ExamService {
|
||||
persistExamDraft(input: unknown): Promise<ActionState<{ examId: string }>>
|
||||
addExamQuestions(examId: string, items: unknown[]): Promise<ActionState<void>>
|
||||
}
|
||||
|
||||
export interface ClassService {
|
||||
getStudentIdsByClassIds(classIds: string[]): Promise<ActionState<{ studentIds: string[] }>>
|
||||
}
|
||||
|
||||
export interface TextbookService {
|
||||
getTextbooks(): Promise<ActionState<{ items: TextbookPickerOption[] }>>
|
||||
getChaptersByTextbookId(textbookId: string): Promise<ActionState<{ tree: ChapterPickerOption[] }>>
|
||||
findChapterById(chapters: ChapterPickerOption[], chapterId: string): ChapterPickerOption | undefined
|
||||
}
|
||||
|
||||
export interface AiService {
|
||||
generateLessonContent(input: unknown): Promise<ActionState<{ content: unknown }>>
|
||||
suggestKnowledgePoints(doc: unknown): Promise<ActionState<{ suggestions: unknown[] }>>
|
||||
}
|
||||
|
||||
// 扩展主接口
|
||||
export interface LessonPlanDataService {
|
||||
// ...现有 16 个方法...
|
||||
|
||||
// 新增对外部服务的访问器(仅返回注入的 service,不直接调用)
|
||||
questionService: QuestionService
|
||||
homeworkService: HomeworkService
|
||||
examService: ExamService
|
||||
classService: ClassService
|
||||
textbookService: TextbookService
|
||||
aiService: AiService
|
||||
}
|
||||
```
|
||||
|
||||
**注入点(`lesson-plan-provider-setup.tsx`)**:
|
||||
|
||||
```typescript
|
||||
"use client"
|
||||
import { useMemo } from "react"
|
||||
import { createDefaultDataService } from "../services/default-data-service"
|
||||
import { createDefaultQuestionService } from "../services/default-question-service" // 新增
|
||||
import { createDefaultHomeworkService } from "../services/default-homework-service" // 新增
|
||||
// ...
|
||||
```
|
||||
|
||||
`default-question-service.ts` 等新增文件实现 `QuestionService` 接口,内部仅 import `@/modules/questions/data-access`(data-access→data-access 合规)。
|
||||
|
||||
`publish-service.ts` 改为接收已注入的 service 对象,不再直接 import `@/modules/questions/data-access` 等。
|
||||
|
||||
### 5.2 组合优先:通过 children/slots/render props 实现灵活性
|
||||
|
||||
**目标**:所有 UI 通过组件组合实现,逻辑复用一律抽取为自定义 hooks。
|
||||
|
||||
**示例 1:LessonPlanCard 多角色通过 props 注入而非分支**:
|
||||
|
||||
```tsx
|
||||
<LessonPlanCard
|
||||
plan={plan}
|
||||
actionsSlot={
|
||||
<>
|
||||
{canEdit && <EditButton planId={plan.id} />}
|
||||
{canPublish && <PublishButton planId={plan.id} />}
|
||||
{canDuplicate && <DuplicateButton planId={plan.id} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
**示例 2:AI 内容生成通过 render props**:
|
||||
|
||||
```tsx
|
||||
<LessonPlanEditor
|
||||
aiContentGenerator={(ctx: AiContext) => <AiLessonContentGenerator {...ctx} />}
|
||||
/>
|
||||
```
|
||||
|
||||
**示例 3:跨模块逻辑抽取为 hooks**:
|
||||
|
||||
```typescript
|
||||
// hooks/use-question-search.ts (新文件,<=80 行)
|
||||
export function useQuestionSearch(service: QuestionService, initialFilters: QuestionPickerParams) {
|
||||
// 纯逻辑 + 状态管理,与 UI 分离
|
||||
// 可独立测试
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 国际化就绪:所有展示文本使用 i18n 键
|
||||
|
||||
**当前状态**:本模块 i18n 已彻底化(zh-CN/en 各 400 行结构一致),本次审计**未发现新的硬编码中文**。
|
||||
|
||||
**翻译文件结构示例**(建议为 P0-3/P1 等新增键):
|
||||
|
||||
```json
|
||||
// shared/i18n/messages/zh-CN/lesson-preparation.json
|
||||
{
|
||||
"error": {
|
||||
"notFound": "未找到课案",
|
||||
"versionNotFound": "未找到版本",
|
||||
"gradeScopeViolation": "无权访问该年级的课案",
|
||||
"permissionDenied": "无权访问此课案"
|
||||
},
|
||||
"picker": {
|
||||
"questionType": {
|
||||
"single_choice": "单选题",
|
||||
"multiple_choice": "多选题",
|
||||
"true_false": "判断题",
|
||||
"short_answer": "简答题",
|
||||
"essay": "论述题"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 最大化复用:识别四角色共用块
|
||||
|
||||
**通用 UI 块**(抽象为泛型组件):
|
||||
|
||||
| 复用单元 | 类型 | 抽象为 |
|
||||
|----------|------|--------|
|
||||
| 课案卡片 | UI | `<LessonPlanCard actionsSlot />` (已实现,需扩展 actionsSlot) |
|
||||
| 课案列表 | UI | `<LessonPlanList viewMode />` (已实现) |
|
||||
| 只读画布 | UI | `<LessonPlanReadonlyView />` (已实现) |
|
||||
| 节点图编辑器 | UI | `<NodeEditor readonly />` (通过 readonly prop 切换) |
|
||||
| 模板选择器 | UI | `<TemplatePicker />` (已实现) |
|
||||
| 版本历史 | UI | `<VersionHistoryDrawer />` (已实现) |
|
||||
|
||||
**通用业务 hooks**(抽取为泛型):
|
||||
|
||||
| 复用单元 | 类型 | 抽象为 |
|
||||
|----------|------|--------|
|
||||
| 课案权限配置 | 逻辑 | `useRoleConfig()` + `ROLE_CONFIGS` 注册表 (已实现,需补 gradeHead) |
|
||||
| 数据 scope 校验 | 逻辑 | `assertPlanInScope(plan, ctx)` (新增,共享) |
|
||||
| 节点编辑器状态 | 逻辑 | `useLessonPlanEditor` (Zustand,需拆 slice) |
|
||||
| 锚点注入算法 | 逻辑 | `lib/anchor-injector.ts` (已实现,纯函数) |
|
||||
| 文档迁移 | 逻辑 | `lib/document-migration.ts` (已实现,纯函数) |
|
||||
|
||||
### 5.5 错误与边界处理
|
||||
|
||||
- **每个独立数据区块用 React Error Boundary 包裹**:在 `LessonPlanEditor` 内对 `NodeEditor`、`VersionHistoryDrawer`、各 Picker/Dialog 独立包裹 `LessonPlanErrorBoundary`。
|
||||
- **React Suspense + 骨架屏**:客户端组件数据加载使用 `<Suspense fallback={<LessonPlanListSkeleton />}>`;消费 `lesson-plan-skeleton.tsx` 已定义的 4 个 Skeleton。
|
||||
- **空数据/无权限/网络异常**:
|
||||
- 空数据:在 `lesson-plan-list.tsx` 已实现空状态文案(V3 续已有)。
|
||||
- 无权限:在 P0-1 中通过 `assertPlanInScope` 抛错或返回 `notFound()`。
|
||||
- 网络异常:`LessonPlanErrorBoundary` 兜底,提供"重试"按钮。
|
||||
|
||||
### 5.6 可测试性
|
||||
|
||||
- **纯逻辑全部放入纯函数或 hooks**:`lib/` 已有 6 个纯函数模块(type-guards/i18n-errors/document-migration/anchor-injector/node-summary/rf-mappers),无 UI 依赖,可独立单元测试。
|
||||
- **建议新增单元测试**:
|
||||
- `lib/type-guards.test.ts`:覆盖 20+ 类型守卫的边界情况
|
||||
- `lib/anchor-injector.test.ts`:覆盖 markdownToPlainText 与 injectPlaceholders 的偏移一致性(修 P2-5 后)
|
||||
- `lib/document-migration.test.ts`:覆盖 v1→v2→v3 链式迁移
|
||||
- `hooks/use-lesson-plan-editor.test.ts`:覆盖 Zustand store 状态变更
|
||||
- **导出清晰的接口类型**:`LessonPlanDataService` 已定义为接口(V3 已实现),测试可注入 mock 实现。
|
||||
|
||||
### 5.7 可扩展性:配置驱动设计
|
||||
|
||||
**目标**:通过角色配置决定该模块渲染哪些 Widget/子模块。
|
||||
|
||||
**当前已部分实现**(`ROLE_CONFIGS` 注册表),需扩展:
|
||||
|
||||
```typescript
|
||||
// 扩展 ROLE_CONFIGS 增加 widget 可见性
|
||||
export interface LessonPlanRoleConfig {
|
||||
canEdit: boolean
|
||||
canPublish: boolean
|
||||
canDelete: boolean
|
||||
canDuplicate: boolean
|
||||
canSaveAsTemplate: boolean
|
||||
// 新增 widget 配置
|
||||
visibleWidgets: {
|
||||
nodeEditor: boolean // 教师可见
|
||||
versionHistory: boolean
|
||||
knowledgePointPicker: boolean
|
||||
questionBankPicker: boolean
|
||||
publishHomeworkDialog: boolean
|
||||
aiContentGenerator: boolean
|
||||
templatePicker: boolean // 仅新建页可见
|
||||
}
|
||||
defaultStatus: LessonPlanStatus // teacher: "draft"; student/parent: "published"
|
||||
}
|
||||
```
|
||||
|
||||
新增角色只需扩展 `ROLE_CONFIGS` 而不动组件代码。
|
||||
|
||||
### 5.8 企业级补充
|
||||
|
||||
**a11y**:
|
||||
- 补全所有 dialog FocusTrap(P1-1)
|
||||
- 补全 textbook-segments 锚点键盘可访问性(P0-3)
|
||||
- 补全按钮 `aria-disabled` / `aria-expanded` / `aria-label`
|
||||
|
||||
**性能**:
|
||||
- 优先使用 React Server Components 获取初始数据(已实现,9 个 page.tsx 均为 async Server Component)
|
||||
- 客户端组件仅负责交互(已实现,全部交互组件有 `"use client"`)
|
||||
- 支持流式渲染(Next.js 15 默认支持,无需额外配置)
|
||||
- 3 个 view 页 textbook/chapters 并行查询(P2-1)
|
||||
|
||||
**安全性**:
|
||||
- 所有敏感数据查询在 data-access 层结合 `ctx.dataScope` 过滤(已实现)
|
||||
- Server Action 二次校验(已实现 `requirePermission()`)
|
||||
- 页面层补齐 `assertPlanInScope`(P0-1)
|
||||
|
||||
**监控埋点**:
|
||||
- `LessonPlanTracker` 接口已预留(V2-6 实现 `useLessonPlanTrackerSafe`)
|
||||
- 6 个关键操作已埋点(create/save/publish/revert/duplicate/archive)
|
||||
- 建议扩展埋点:a11y 违规、Error Boundary 触发、节点图加载时长
|
||||
|
||||
---
|
||||
|
||||
## 六、文档实施计划
|
||||
|
||||
### 第一阶段:本次审计内实施(P0 + P1 + P2)
|
||||
|
||||
按本报告"四、改进优先级建议"中的 P0、P1、P2 共 30 项全部完成代码修改,并运行 `npm run lint` 与 `npx tsc --noEmit` 验证零错误。
|
||||
|
||||
### 第二阶段:中长期独立立项(M1-M12)
|
||||
|
||||
中长期计划需各自独立立项,本次审计仅记录在案,不在本次实施范围内:
|
||||
- M1 课标对标体系(高优,影响 K12 合规性)
|
||||
- M2 协同备课(高优,影响教研效率)
|
||||
- M3 审核工作流(中优)
|
||||
- M4 课程地图(中优)
|
||||
- M5 形成性评价闭环(中优)
|
||||
- M6 资源附件库(低优)
|
||||
- M7 移动端 PWA(低优)
|
||||
- M8 AI 差异化生成(中优)
|
||||
- M9 日历视图(低优)
|
||||
- M10 备课分析仪表盘(中优)
|
||||
- M11 版本 diff 预览(低优)
|
||||
- M12 代课教师机制(低优)
|
||||
|
||||
---
|
||||
|
||||
## 七、架构图同步说明
|
||||
|
||||
本次审计**未发现架构图遗漏节点**,004/005 文档与本模块当前代码状态一致。但需在 `005_architecture_data.json` 的 `modules.lesson_preparation.auditFixes` 中**新增以下 v4 条目**:
|
||||
|
||||
| ID | 内容 |
|
||||
|----|------|
|
||||
| V4-1 | P0-1 parent/student view gradeId scope 校验:新增 `assertPlanInScope` 共享函数,两路由页面调用 |
|
||||
| V4-2 | P0-2 9 个页面 requirePermission:补全 LESSON_PLAN_READ 权限校验 |
|
||||
| V4-3 | P0-3 textbook-segments a11y:锚点 span 添加 role=button + tabIndex + onKeyDown |
|
||||
| V4-4 | P0-4 default-data-service 解耦:新增 QuestionService/HomeworkService/ExamService/ClassService/TextbookService/AiService 6 个接口,由 lesson-plan-provider-setup 在 app 层注入 data-access 实现 |
|
||||
| V4-5 | P1-1 4 个 dialog FocusTrap + ESC:包裹 FocusTrap + 监听 ESC |
|
||||
| V4-6 | P1-2 Suspense + 骨架屏:客户端组件消费 4 个已定义 Skeleton |
|
||||
| V4-7 | P1-3 Error Boundary 全覆盖:NodeEditor/VersionHistoryDrawer/Picker/Dialog 独立包裹 |
|
||||
| V4-8 | P1-4 硬编码颜色:提取到 globals.css CSS 变量 + NODE_COLORS 映射表 |
|
||||
| V4-9 | P1-5 exercise-block Link:替换原生 `<a>` |
|
||||
| V4-10 | P1-6 6 个 block key 改业务唯一 key |
|
||||
| V4-11 | P1-7 template-picker cn():替换字符串拼接 |
|
||||
| V4-12 | P1-8 block-registry use client |
|
||||
| V4-13 | P1-9 NodeEditPanel knownTypes 改 Object.keys(BLOCK_REGISTRY) |
|
||||
| V4-14 | P1-10 inline-question-editor 复用 VALID_QUESTION_TYPES |
|
||||
| V4-15 | P1-11 ROLE_CONFIGS 新增 gradeHead |
|
||||
| V4-16 | P1-12 useRoleConfig 开发环境 warn |
|
||||
| V4-17 | P1-13 i18n-errors.ts as 断言修复 |
|
||||
| V4-18 | P1-14 data-access-templates 重复类型守卫清理 |
|
||||
| V4-19 | P1-15 data-access-knowledge 重复 .map 提取 |
|
||||
| V4-20 | P1-16 actions.ts L187 改 i18n key |
|
||||
| V4-21 | P2-1 3 个 view 页 textbook/chapters 并行 |
|
||||
| V4-22 | P2-2 3 个 view 页错误处理统一 notFound() |
|
||||
| V4-23 | P2-3 Promise.resolve().then() 改 useEffect+ignore flag |
|
||||
| V4-24 | P2-4 block-renderer.tsx @deprecated 删除 |
|
||||
| V4-25 | P2-5 anchor-injector skipPatterns 一致性修复 + 单测 |
|
||||
| V4-26 | P2-6 use-lesson-plan-editor Zustand 拆 slice |
|
||||
|
||||
`004_architecture_impact_map.md` §2.27 章节末尾追加"V4 审计修复"小节,简述上述条目。
|
||||
|
||||
---
|
||||
|
||||
**审计完成。下一步:实施第一阶段全部 30 项修复。**
|
||||
648
docs/architecture/audit/messaging-audit-report.md
Normal file
648
docs/architecture/audit/messaging-audit-report.md
Normal file
@@ -0,0 +1,648 @@
|
||||
# 私信(messaging)模块审计报告
|
||||
|
||||
> 审查日期:2026-06-25
|
||||
> 审查范围:`src/modules/messaging/**`、`src/app/(dashboard)/messages/**`、`src/modules/layout/components/app-sidebar.tsx`(消费方)、`src/shared/i18n/messages/{zh-CN,en}/messages.json`
|
||||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.13、`docs/architecture/005_architecture_data.json` §messaging
|
||||
> 前置文档:`docs/architecture/audit/announcements-messages-audit-report-v3.md`(已修复 V3-P0-1 / V3-P1-3 / V3-P1-6 / V3-P2-7)
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|----|------|------|------|
|
||||
| 路由 | `app/(dashboard)/messages/page.tsx` | 37 | 消息首页(编排 + 渲染 MessageList + NotificationList) |
|
||||
| 路由 | `app/(dashboard)/messages/[id]/page.tsx` | 33 | 消息详情页 |
|
||||
| 路由 | `app/(dashboard)/messages/compose/page.tsx` | 46 | 撰写消息页 |
|
||||
| 路由 | `app/(dashboard)/messages/{,[id]/,compose/}{error,loading}.tsx` | 6 文件 | 错误边界 + 骨架屏 |
|
||||
| Actions | `modules/messaging/actions.ts` | ~329 | 11 个 Server Action(含 1 个死代码 Action) |
|
||||
| Data-access | `modules/messaging/data-access.ts` | ~404 | 私信 CRUD + 草稿 CRUD + 2 个编排函数 |
|
||||
| Schema | `modules/messaging/schema.ts` | 40 | Zod 校验(发送 / messageId / 草稿) |
|
||||
| Types | `modules/messaging/types.ts` | 83 | 私信 + 草稿类型 |
|
||||
| 组件 | `components/message-list.tsx` | 252 | 列表 + 客户端搜索 + 分页 + 星标切换 |
|
||||
| 组件 | `components/message-detail.tsx` | 179 | 详情 + 删除 + 星标 + 回复入口 |
|
||||
| 组件 | `components/message-compose.tsx` | 284 | 撰写 + 自动保存草稿 + 字段级校验 |
|
||||
| 组件 | `components/unread-message-badge.tsx` | 57 | 侧边栏未读徽章(30s 轮询) |
|
||||
| Hook | `hooks/use-message-search.ts` | 106 | 防抖 + 请求竞态取消 |
|
||||
| 测试 | `schema.test.ts` | 242 | SendMessageSchema / MessageIdSchema 单元测试 |
|
||||
| i18n | `shared/i18n/messages/{zh-CN,en}/messages.json` | 89 | 13 命名空间翻译键 |
|
||||
|
||||
文件大小均在规范内(最大 `data-access.ts` 404 行 < 800 行限制;最大组件 `message-compose.tsx` 284 行 < 500 行限制)。
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
[messages 页面]
|
||||
└─ getMessagesPageData(userId) # data-access 编排
|
||||
├─ getMessages({type:"all"}) # messaging 自有表
|
||||
└─ import("@/modules/notifications/data-access").getNotifications(userId) # ⚠ 动态跨模块 import
|
||||
→ MessageList(客户端 useMessageSearch 搜索 + 客户端 tab 过滤 + 客户端分页)
|
||||
→ NotificationList(notifications 模块组件)
|
||||
|
||||
[messages/[id] 页面]
|
||||
└─ getMessageDetailPageData(id, userId) # data-access 编排
|
||||
├─ getMessageById(id, userId) # 权限校验在 SQL where 中
|
||||
└─ markMessageAsRead(id, userId) # 接收方未读时自动标记
|
||||
→ MessageDetail(客户端调用 deleteMessageAction / toggleMessageStarAction)
|
||||
|
||||
[messages/compose 页面]
|
||||
└─ getRecipients(userId, dataScope) # 按 DataScope 解析可发送对象
|
||||
└─ classes data-access.getTeacherIdsByClassIds / getStudentActiveClassId
|
||||
└─ users data-access.getUserNamesByIds
|
||||
→ MessageCompose(自动保存调用 saveMessageDraftAction;提交调用 sendMessageAction)
|
||||
```
|
||||
|
||||
### 1.3 架构图与实际代码一致性核对
|
||||
|
||||
- ✅ `004_architecture_impact_map.md` §2.13 列出的导出函数与实际 `actions.ts` / `data-access.ts` 基本一致
|
||||
- ❌ **不一致点 1**:架构图标注 `getMessageDetailAction` 的 `usedBy` 含 `message-detail.tsx`,但实际代码中 `message-detail.tsx` **从未调用** `getMessageDetailAction`(详情数据由 RSC 通过 `getMessageDetailPageData` 直接获取)
|
||||
- ❌ **不一致点 2**:架构图标注 `getMessageDraftsAction` 的 `usedBy` 含 `messages/compose/page.tsx`,但实际 `compose/page.tsx` **从不调用** `getMessageDraftsAction`(草稿仅在客户端自动保存,未提供"草稿列表"UI)
|
||||
- ❌ **不一致点 3**:架构图 §2.13 文件清单中 `actions.ts` 标注 "~330 行 11 个私信 Server Action",但实际包含 1 个完全未被引用的死代码 Action(`getMessageDetailAction`)
|
||||
- ❌ **不一致点 4**:架构图标注 `getMessageThread` 的签名是 `(rootId: string, userId: string)`,实际代码签名是 `(messageId: string)` 且**未做 userId 权限校验**
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 【P0 - 安全】`createMessage` 未校验 receiverId 是否在 sender 的 DataScope 内
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L32-95 `sendMessageAction` | 仅校验 `receiverId !== ctx.userId`,未校验 receiverId 是否在 `ctx.dataScope` 允许范围内 | 安全规范:"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验" |
|
||||
|
||||
**问题分析**:
|
||||
- `getRecipientsAction` 通过 `getRecipients(ctx.userId, ctx.dataScope)` 返回收件人列表,UI 通过 Select 限定选项
|
||||
- 但 `sendMessageAction` 直接接收 `formData.get("receiverId")`,未与 `ctx.dataScope` 二次校验
|
||||
- 攻击者绕过前端 Select,构造 FormData 提交任意 `receiverId`,即可向任意用户发送消息
|
||||
- 学生 A 可向学生 B(非同班)发消息;家长可向非自己孩子老师发消息;越权发送
|
||||
|
||||
**后果**:横向越权 / 信息泄露 / 骚扰风险。K12 场景下家长-学生-教师通信边界被破坏,合规风险极高。
|
||||
|
||||
### 2.2 【P0 - 安全】`getMessageThread` 缺少 userId 权限校验
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L156-169 `getMessageThread` | 仅 `eq(messages.id, messageId)` + `eq(messages.parentMessageId, messageId)`,未过滤 `senderId`/`receiverId` | 安全规范:"data-access 层结合当前用户权限过滤" |
|
||||
|
||||
**问题分析**:
|
||||
- 函数签名是 `getMessageThread(messageId: string)`,无 `userId` 参数
|
||||
- 任意调用方传入合法 messageId 即可读取整个回复链
|
||||
- 当前调用方 `getMessageDetailAction`([actions.ts:177](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts))虽先调用 `getMessageById(id, ctx.userId)` 校验根消息访问权,但 `getMessageThread` 本身缺乏防御深度
|
||||
- 若未来有新调用方(如导出、统计、AI 摘要)误用 `getMessageThread`,将直接泄露隐私
|
||||
|
||||
**后果**:纵深防御缺失;隐私消息可被未授权读取。
|
||||
|
||||
### 2.3 【P0 - 架构】`getMessagesPageData` 在 data-access 层动态 import 跨模块
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L287 `await import("@/modules/notifications/data-access")` | data-access 层动态 import notifications 模块 | 重构方案原则:"完全解耦:模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access" + 架构分层规则:"app/ 只能调用 modules 的 Server Actions 和 data-access" |
|
||||
|
||||
**问题分析**:
|
||||
- V1-P1-5 的修复将"页面层 `Promise.all` 编排 messaging + notifications"改为"data-access 编排",但编排本身跨模块,应放在页面层
|
||||
- 当前 `getMessagesPageData` 强依赖 notifications 模块,破坏 messaging 模块独立性
|
||||
- mock 测试 messaging 时必须 stub notifications 模块
|
||||
- 单元测试边界模糊:messaging 模块的 data-access 测试是否应包含 notifications 行为?
|
||||
|
||||
**后果**:模块边界泄漏;测试困难;重构 notifications 时会波及 messaging。
|
||||
|
||||
### 2.4 【P0 - 死代码】`getMessageDetailAction` 完全无引用
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L167-190 `getMessageDetailAction` | 定义后从未被任何组件、页面、其他 Action 调用 | 编码规范:"避免 backwards-compatibility hacks... 如果确定某物未使用,可以完全删除" |
|
||||
|
||||
**问题分析**:
|
||||
- 全代码库 grep `getMessageDetailAction` 仅命中 `actions.ts` 自身、架构图 004/005、历史 bug 文档
|
||||
- 详情页使用 `getMessageDetailPageData`(data-access 编排函数)替代
|
||||
- 详情页组件 `MessageDetail` 通过 props 接收 `message`,不调用此 Action
|
||||
- 此 Action 是 V2-P1-3 重构时遗留的死代码
|
||||
|
||||
**后果**:维护负担;架构图与代码不一致;新人误以为是 API 入口。
|
||||
|
||||
### 2.5 【P0 - 安全/正确性】`MessageCompose` 自动保存草稿在 `useEffect` 闭包中读 `draftIdRef`,但 `subject`/`content` 仍触发新 effect
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-compose.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-compose.tsx) L67-102 `useEffect` | 依赖 `[subject, content, receiverId, parentMessageId]`,每次键入触发新 effect → 2 秒后保存;用户快速提交时仍在保存旧草稿 | 可测试性:"纯逻辑全部放入纯函数或 hooks,与 UI 分离" |
|
||||
|
||||
**问题分析**:
|
||||
- 用户输入"Hello" → 2 秒后保存草稿(draftId=A)
|
||||
- 用户在 1.8 秒时点击"发送" → `handleSubmit` 发送消息 + `deleteMessageDraftAction(draftIdRef.current=A)` 删除草稿
|
||||
- 0.2 秒后,第一次 effect 的 setTimeout 触发,`cancelled` 仍为 false(清理函数仅在 effect 重新运行或卸载时调用) → 重新调用 `saveMessageDraftAction` 创建新草稿(draftId=B)
|
||||
- 用户已离开页面,但数据库中残留孤立草稿 B
|
||||
|
||||
**后果**:数据库残留垃圾草稿;用户隐私风险("已发送"的内容仍以草稿形式存留)。
|
||||
|
||||
### 2.6 【P1 - 功能缺失】草稿列表 UI 完全缺失
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `messages/compose/page.tsx` 全文 | 未调用 `getMessageDraftsAction`,无草稿列表渲染 | 最大化复用:"识别共用的 UI 块和业务逻辑块" |
|
||||
|
||||
**问题分析**:
|
||||
- data-access 提供 `getMessageDrafts` / `getMessageDraftById`
|
||||
- actions 提供 `getMessageDraftsAction` / `deleteMessageDraftAction`
|
||||
- i18n 已准备 `empty.noDrafts` / `messages.draftSaved` / `messages.draftDeleted` 翻译键
|
||||
- 但 compose 页面无"草稿列表"区块;用户无法查看、恢复、删除草稿
|
||||
- 自动保存创建的草稿一旦离开页面即"丢失"(数据库存在但 UI 不可见)
|
||||
|
||||
**后果**:草稿功能闭环不完整;用户感知"自动保存"无效;数据库持续堆积无主草稿。
|
||||
|
||||
### 2.7 【P1 - 功能缺失】"星标"筛选 Tab 缺失
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L23 `type Tab = "inbox" \| "sent"` | 缺少 `starred` Tab,但 data-access 支持 `starredOnly` 筛选 | 可扩展性:"配置驱动设计" |
|
||||
|
||||
**问题分析**:
|
||||
- `getMessages` 已支持 `starredOnly: true` 筛选
|
||||
- `toggleMessageStarAction` 允许接收方星标消息
|
||||
- i18n 已准备 `empty.noStarred` / `actions.star` / `actions.unstar` 翻译键
|
||||
- 但 MessageList 仅有 inbox / sent 两个 Tab,星标消息只能逐条浏览无法快速筛选
|
||||
- "星标"语义在 K12 场景下对应"重要消息",是家长/教师高优先级信息检索的核心入口
|
||||
|
||||
**后果**:星标功能存在但 UX 不可用;用户无法快速查找重要消息。
|
||||
|
||||
### 2.8 【P1 - 功能缺失】消息线程 UI 完全缺失
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-detail.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-detail.tsx) 全文 | 仅渲染根消息,不渲染 `getMessageThread` 返回的回复链 | 行业差距:K12 标准产品(Remind / ClassDojo / ParentSquare)均支持会话视图 |
|
||||
|
||||
**问题分析**:
|
||||
- `getMessageThread` 已实现并返回 `Message[]`(根 + 回复)
|
||||
- `Message` 类型有 `parentMessageId` 字段支持回复链
|
||||
- `MessageCompose` 支持 `parentMessageId` 参数创建回复
|
||||
- 但 `MessageDetail` 仅渲染单条 `message`,不获取也不渲染线程
|
||||
- 用户回复后无法看到完整对话,只能逐条点开"详情"查看
|
||||
|
||||
**后果**:沟通上下文丢失;教师/家长需要记忆完整对话;多轮沟通效率低。
|
||||
|
||||
### 2.9 【P1 - i18n 遗漏】`MessageList` 分页 aria-label 硬编码英文
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L230 `aria-label="Previous page"`、L242 `aria-label="Next page"`、L224 `aria-label={t("tabs.inbox")}` 用作分页导航 label | 硬编码英文字符串;分页 nav 的 aria-label 错误使用 "tabs.inbox" 翻译 | i18n 规范:"所有用户可见文本必须适配 i18n" |
|
||||
|
||||
**问题分析**:
|
||||
- 屏幕阅读器在中文环境下朗读 "Previous page" 而非"上一页"
|
||||
- 分页 nav 的 aria-label 使用 `t("tabs.inbox")`("收件箱"),与"分页"语义无关,无障碍用户感知混乱
|
||||
- 搜索框加载状态未使用 `aria-busy`
|
||||
- 星标切换按钮未设置 `aria-pressed` 反映开关状态
|
||||
|
||||
**后果**:a11y 体验降级;中文用户依赖屏幕阅读器时无法理解控件用途。
|
||||
|
||||
### 2.10 【P1 - 跨模块依赖】`UnreadMessageBadge` 仍用轮询,未复用 notifications 模块的 SSE 实时通道
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [unread-message-badge.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/unread-message-badge.tsx) L19 `POLL_INTERVAL_MS = 30_000` | 每 30 秒轮询 `getUnreadMessageCountAction`,notifications 模块已实现 SSE 实时推送 | 企业级:"支持流式渲染";性能 |
|
||||
|
||||
**问题分析**:
|
||||
- notifications 模块已有 `/api/notifications/stream` SSE 端点 + `useNotificationStream` hook
|
||||
- SSE 端点目前仅推送通知数据,未含私信未读数
|
||||
- `UnreadMessageBadge` 每 30 秒发起一次 Server Action 调用 → 数据库 COUNT 查询
|
||||
- 多用户并发时形成规律性 DB 压力(N 用户 × 2 次/分钟 = 2N 次/分钟)
|
||||
- 组件注释已识别此问题但未实施
|
||||
|
||||
**后果**:实时性差(最坏延迟 30 秒);DB 压力随用户数线性增长;用户体验与通知模块割裂。
|
||||
|
||||
### 2.11 【P1 - 类型安全】`getRecipients` 返回的 `role` 字段使用字符串字面量
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L243/254/262/272 `role: "student"` / `role: "teacher"` | `RecipientOption.role` 类型为 `string?`,丢失角色枚举语义 | TypeScript 规则:"禁止 `any`;函数返回值必须显式标注" |
|
||||
|
||||
**问题分析**:
|
||||
- `RecipientOption.role?: string` 类型过宽,调用方无法依赖具体角色值
|
||||
- 实际只赋值 `"student"` 或 `"teacher"`,但类型系统不知情
|
||||
- 调用方 `MessageCompose` 渲染 `{r.role ? ` (${r.role})` : ""}`,无法做角色图标/颜色映射
|
||||
- admin 角色未在 `getRecipients` 中标识(scope.type==="all" 分支未设 role)
|
||||
|
||||
**后果**:类型安全缺失;UI 无法基于角色做差异化展示;admin 收件人列表无角色信息。
|
||||
|
||||
### 2.12 【P1 - 可测试性】`MessageList` 直接 import `getMessagesAction` 与 `toggleMessageStarAction`
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L19 `import { getMessagesAction, toggleMessageStarAction } from "../actions"` | 组件直接依赖具体 Action 实现,未通过接口注入 | 重构方案原则:"通过定义 TypeScript 接口抽象数据依赖,使用 React Context(或组合 Provider)注入数据服务" |
|
||||
|
||||
**问题分析**:
|
||||
- `useMessageSearch` hook 已支持 `searchAction` 参数注入(良好)
|
||||
- 但 `MessageList` 在调用处直接传入 `getMessagesAction`,且 `handleToggleStar` 直接调用 `toggleMessageStarAction`
|
||||
- 单元测试 `MessageList` 必须 mock 模块导入(`vi.mock("../actions")`),无法通过 props 替换
|
||||
- 未来支持多角色时(如 admin 群发 vs 学生单发)无法注入不同实现
|
||||
|
||||
**后果**:测试需依赖 mock 框架而非依赖注入;扩展角色场景需修改组件。
|
||||
|
||||
### 2.13 【P1 - UX】`replyHref` 构建逻辑放在组件内且 URL 参数未编码
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-detail.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-detail.tsx) L86-90 `replyHref` | 在组件内拼接 URL;`receiverId` 未 `encodeURIComponent`;"Re:" 前缀逻辑硬编码 | 可测试性:"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks" |
|
||||
|
||||
**问题分析**:
|
||||
- `subject` 用了 `encodeURIComponent`,`receiverId` 没有(虽然 cuid 一般无特殊字符,但防御性缺失)
|
||||
- "Re:" 前缀判断逻辑 `message.subject?.startsWith("Re:")` 在组件内
|
||||
- 多次回复会产生 `Re: Re: Re:` 链,UI 丑陋
|
||||
- 此逻辑应抽取为纯函数 `buildReplyHref(message)` 便于测试
|
||||
|
||||
**后果**:逻辑散落;测试需渲染组件;多回复标题累积。
|
||||
|
||||
### 2.14 【P1 - 一致性】`MessageList` `initialType` 默认 "inbox" 与页面传入 `type:"all"` 数据不匹配
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L40 `useState<Tab>(initialType === "sent" ? "sent" : "inbox")` | `initialType` 默认 "inbox",但页面调用 `getMessagesPageData` 传入 `type:"all"`,组件内 `isUsingInitial` 分支再按 tab 客户端过滤 | 一致性 + 性能 |
|
||||
|
||||
**问题分析**:
|
||||
- 服务端获取 type=all 的 50 条数据
|
||||
- 客户端根据 tab=inbox 过滤出 receiverId===currentUserId 的子集
|
||||
- 用户切换到 sent Tab 时,`useMessageSearch` 触发服务端搜索 type=sent(因 keyword 为空时 `isUsingInitial=true`,但搜索逻辑仅在 keyword 非空时触发)
|
||||
- 实际行为:用户点 sent Tab 时,列表显示客户端过滤后的 sent 子集;用户输入关键字搜索时才走服务端
|
||||
- 这种"客户端过滤初始数据 + 服务端搜索关键字"的双轨制容易导致边界 bug(如初始数据不含已删除但客户端过滤后误显示)
|
||||
|
||||
**后果**:行为不一致;潜在边界 bug;理解成本高。
|
||||
|
||||
### 2.15 【P2 - 性能】`UnreadMessageBadge` 卸载时不取消 in-flight 请求
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [unread-message-badge.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/unread-message-badge.tsx) L24-45 `useEffect` | `active` 标志阻止 `setCount`,但 `fetchCount` 已发起的 Promise 无法取消 | 性能 |
|
||||
|
||||
**问题分析**:
|
||||
- 组件卸载时 `clearInterval` 停止后续轮询
|
||||
- 但若卸载发生在 `getUnreadMessageCountAction` 调用后、Promise resolve 前,Server Action 仍会执行完成
|
||||
- `active=false` 仅阻止 setState,不阻止网络请求和 DB 查询
|
||||
- 快速路由跳转会累积 orphaned Promises
|
||||
|
||||
**后果**:轻微资源浪费;高并发跳转场景下 DB 压力瞬时增加。
|
||||
|
||||
### 2.16 【P2 - UX】无批量操作
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `message-list.tsx` 全文 | 无多选、无批量已读、无批量删除 | 行业差距 |
|
||||
|
||||
**问题分析**:
|
||||
- K12 教师每学期可能收到数百条家长消息
|
||||
- 当前 UI 仅支持逐条操作
|
||||
- 缺少"全部已读"、"批量归档"等效率功能
|
||||
- 通知模块已有"全部标记已读",私信模块未对齐
|
||||
|
||||
**后果**:教师处理消息效率低;重要消息被淹没。
|
||||
|
||||
### 2.17 【P2 - 监控】`sendMessageAction` 失败时无告警埋点
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L90-94 catch 块 | 仅返回 `{success:false, message}`,未调用 `trackEvent` 记录失败 | 监控:"方案中预留关键操作埋点接口" |
|
||||
|
||||
**问题分析**:
|
||||
- 成功路径有 `trackEvent("message.sent")`
|
||||
- 失败路径(DB 异常、权限拒绝)无埋点
|
||||
- 无法监控发送失败率、权限拒绝率
|
||||
- 用户上报"消息发不出去"时无法在监控中复现
|
||||
|
||||
**后果**:故障不可观测;SLA 无法度量。
|
||||
|
||||
### 2.18 【P2 - 安全】`parentMessageId` 未校验属于当前用户会话
|
||||
|
||||
| 位置 | 代码 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L55-61 `createMessage` 调用 | `parentMessageId` 来自 FormData,未校验是否属于当前用户参与的会话 | 安全规范 |
|
||||
|
||||
**问题分析**:
|
||||
- 用户 A 构造 FormData `{receiverId: B, parentMessageId: msg_between_C_and_D}` 创建消息
|
||||
- 消息会插入 `parentMessageId = msg_between_C_and_D`
|
||||
- `getMessageThread(msg_between_C_and_D)` 会将此消息加入他人会话线程
|
||||
- 越权注入会话上下文
|
||||
|
||||
**后果**:会话上下文污染;隐私泄露。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
参考 K12 教育沟通类主流产品(Remind、ClassDojo、ParentSquare、Bloomz、Classting、智学网家校通、企业微信家校应用)总结差距:
|
||||
|
||||
| 维度 | 行业优秀实践 | 当前实现 | 影响 |
|
||||
|------|-------------|---------|------|
|
||||
| **会话视图** | Remind / ParentSquare 以"会话"为单元,单页内展示完整来回对话,支持滚动加载历史 | 单条详情 + 单独"回复"链接跳转 compose 页 | 多轮沟通上下文丢失;用户需记忆对话;教师回复效率降低 50%+ |
|
||||
| **实时推送** | Remind / ClassDojo 全部基于 push(FCM/APN/WebSocket),消息送达延迟 < 2 秒 | 30 秒轮询;SSE 端点未覆盖私信 | 实时性差;高峰期 DB 负担;移动端电池消耗 |
|
||||
| **草稿恢复** | Gmail / Outlook 在 compose 入口直接列出未完成草稿,支持一键继续 | 草稿自动保存但无列表 UI;用户离开后草稿"消失" | 用户感知"自动保存无效";重要内容丢失风险 |
|
||||
| **重要消息标记** | ParentSquare 支持"标星 + 旗标 + 优先级"三维度筛选;侧边栏快捷入口"已标星" | data-access 支持星标筛选但 UI 未暴露 | 用户无法快速检索重要消息 |
|
||||
| **批量操作** | 企业微信家校应用、Outlook 支持"全选 + 批量已读 + 批量归档" | 仅逐条操作 | 教师每学期处理数百条消息效率低 |
|
||||
| **群组消息** | Remind 支持"班级群发"(教师→全班家长,家长单独回复) | 仅 1v1 私信;班级通知由 announcements 模块覆盖但无家长回复通道 | 家长-教师群组沟通需第三方工具 |
|
||||
| **附件支持** | Remind / ClassDojo 支持图片、文档、语音附件 | 纯文本消息 | 作业截图、家访照片等无法通过私信传输 |
|
||||
| **已读回执** | WhatsApp / 企业微信显示"已读"小蓝双勾 | 有 `readAt` 字段但 UI 仅显示"阅读于 xxx" | 发送方无法快速确认对方是否已读 |
|
||||
| **输入指示器** | iMessage / 微信显示"对方正在输入..." | 无 | 实时沟通场景下体验割裂 |
|
||||
| **家长侧角色感知** | ParentSquare 列表中显示"数学老师"、"班主任"角色 tag | `role` 字段为 "teacher" 字符串,无学科/职务信息 | 家长无法识别消息来自哪位老师 |
|
||||
| **草稿冲突** | Gmail 跨设备草稿同步 + 冲突检测 | 仅本地自动保存,无冲突处理 | 多设备切换时草稿丢失 |
|
||||
| **消息撤回** | 企业微信支持 2 分钟内撤回 | 无撤回功能 | 教师误发后只能删除(仅对自己删除) |
|
||||
| **举报 / 屏蔽** | 主流产品支持举报不当内容 + 屏蔽用户 | 无 | K12 合规风险(家长-教师纠纷) |
|
||||
| **快捷回复模板** | 教师场景常用"已收到"、"稍后回复"等模板 | 无 | 教师重复输入相同内容 |
|
||||
| **搜索范围** | Outlook 支持按发件人、日期、附件、已读状态组合搜索 | 仅 subject + content 关键词搜索 | 历史消息检索困难 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急 - 安全/正确性/死代码)
|
||||
|
||||
| 编号 | 标题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | `sendMessageAction` 增加 receiverId 二次校验 | 在 Action 中调用 `getRecipients(ctx.userId, ctx.dataScope)`,断言 `receiverId ∈ recipients[].id`;或抽取 `isReceiverAllowed(userId, receiverId, scope)` 纯函数 |
|
||||
| P0-2 | `getMessageThread` 增加 userId 参数 + 权限校验 | 修改签名为 `getMessageThread(messageId, userId)`,where 子句加 `(senderId=userId OR receiverId=userId)` |
|
||||
| P0-3 | `getMessagesPageData` 编排迁出 data-access | 删除此函数;页面层 `Promise.all([getMessages(...), getNotifications(...)])`(架构规则允许 app 调用多个 data-access) |
|
||||
| P0-4 | 删除死代码 `getMessageDetailAction` | 直接删除;同步架构图 004/005 |
|
||||
| P0-5 | `MessageCompose` 自动保存竞态修复 | 提交时设置 `submittedRef`;自动保存 effect 检查 `submittedRef` 跳过;卸载时清理 timeout |
|
||||
| P0-6 | `parentMessageId` 校验属于当前用户 | 在 Action 中调用 `getMessageById(parentMessageId, ctx.userId)`,断言返回非 null |
|
||||
|
||||
### P1(重要 - 功能/UX/i18n/性能)
|
||||
|
||||
| 编号 | 标题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | compose 页面新增"草稿列表"区块 | 调用 `getMessageDraftsAction` 渲染列表;支持"继续编辑"(带 draftId 参数回到 compose)+"删除草稿" |
|
||||
| P1-2 | MessageList 新增"星标" Tab | Tab 增 `starred`;调用 `getMessagesAction({type:"all", starredOnly:true})`;复用 `useMessageSearch` |
|
||||
| P1-3 | MessageDetail 渲染消息线程 | 调用 `getMessageThread(messageId, userId)` 渲染回复链;新增 `MessageThread` 子组件;时间正序展示 |
|
||||
| P1-4 | 分页 aria-label i18n 化 + 修正语义 | 新增 i18n 键 `pagination.nav` / `pagination.previous` / `pagination.next`;星标按钮加 `aria-pressed` |
|
||||
| P1-5 | `UnreadMessageBadge` 接入 SSE | 扩展 `/api/notifications/stream` 端点 payload 含 `unreadMessageCount`;hook 订阅;轮询降级保留 |
|
||||
| P1-6 | `RecipientOption.role` 类型收紧 | 新增 `RecipientRole = "student" \| "teacher" \| "admin" \| "parent"` 类型;admin 分支补 role |
|
||||
| P1-7 | `replyHref` 逻辑抽取为纯函数 | 新增 `buildReplyHref(message, canSend): string \| undefined`;用 `URLSearchParams` 构建参数;纯函数单元测试 |
|
||||
| P1-8 | `MessageList` 依赖注入化 | 新增 `MessageListService` 接口(`search` + `toggleStar`);通过 props 注入;默认实现调用现有 Action |
|
||||
| P1-9 | `sendMessageAction` 失败埋点 | catch 块调用 `trackEvent({event:"message.send_failed", reason})` |
|
||||
|
||||
### P2(中长期 - 架构/扩展性)
|
||||
|
||||
| 编号 | 标题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 批量操作(已读/删除/星标) | 新增 `bulkMarkReadAction` / `bulkDeleteAction` / `bulkToggleStarAction`;MessageList 加多选模式 |
|
||||
| P2-2 | 群组消息(教师→全班家长) | 新增 `group_messages` 表 + `sendGroupMessageAction`;家长侧聚合为"会话" |
|
||||
| P2-3 | 附件支持 | 新增 `message_attachments` 表 + 上传走 shared/storage;compose 加附件区 |
|
||||
| P2-4 | 消息撤回 | 新增 `recallMessageAction`(2 分钟窗口);UI 显示"已撤回"占位 |
|
||||
| P2-5 | 举报 + 屏蔽 | 新增 `message_reports` 表 + `reportMessageAction` / `blockUserAction` |
|
||||
| P2-6 | 快捷回复模板 | 用户偏好存储模板;compose 加"插入模板"下拉 |
|
||||
| P2-7 | 配置驱动的角色组合 | 定义 `MessagingRoleConfig` 接口;admin/teacher/parent/student 各自实现;通过 Context 注入;新增角色仅新增配置 |
|
||||
| P2-8 | Error Boundary 细化 | 列表区/详情区/草稿区分别包裹 ErrorBoundary;局部失败不影响整体 |
|
||||
| P2-9 | React Suspense 流式渲染 | RSC 使用 `Suspense` 包裹 MessageList;fallback 用骨架屏;初始数据流式传输 |
|
||||
| P2-10 | 跨设备草稿同步 | 草稿表加 `deviceId` + `version` 字段;冲突时取最新 |
|
||||
| P2-11 | 组合搜索(按发件人/日期/已读) | `getMessages` 扩展 `senderId` / `dateRange` / `isRead` 参数;UI 加高级搜索面板 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 需要补充/修改的节点
|
||||
|
||||
| 文档 | 位置 | 修改类型 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| 004 §2.13 | 导出函数 - Actions | 删除 | 移除 `getMessageDetailAction`(P0-4 死代码) |
|
||||
| 004 §2.13 | Data-access - `getMessagesPageData` | 删除 | 移除此编排函数(P0-3 迁出至页面层) |
|
||||
| 004 §2.13 | Data-access - `getMessageThread` | 修改签名 | `(messageId: string)` → `(messageId: string, userId: string)`(P0-2) |
|
||||
| 004 §2.13 | Data-access - 新增 `isReceiverAllowed` | 新增 | 校验收件人是否在 sender DataScope(P0-1) |
|
||||
| 004 §2.13 | 已知问题 | 新增 | 记录 P0-1 ~ P0-6 修复状态 |
|
||||
| 004 §2.13 | 文件清单 | 更新行数 | 删除 `getMessageDetailAction` 后 actions.ts 行数变化 |
|
||||
| 005 §messaging.actions[] | `getMessageDetailAction` 节点 | 删除 | 同步删除 |
|
||||
| 005 §messaging.dataAccess[] | `getMessagesPageData` 节点 | 删除 | 同步删除 |
|
||||
| 005 §messaging.dataAccess[] | `getMessageThread` 节点 | 更新签名 | `userId` 参数 + 权限校验 |
|
||||
| 005 §messaging.dataAccess[] | 新增 `isReceiverAllowed` 节点 | 新增 | P0-1 |
|
||||
| 005 §messaging.routes[] | `/messages/compose` | 更新 usedBy | 新增 `getMessageDraftsAction` 真实调用(P1-1) |
|
||||
| 005 §dependencyMatrix | messaging → notifications | 降级 | 删除 `getMessagesPageData` 后,messaging data-access 不再直接依赖 notifications(仅 actions 层通过 dispatcher 调用) |
|
||||
|
||||
---
|
||||
|
||||
## 六、实施清单(本次将完成)
|
||||
|
||||
> 本审计报告撰写完成后,将立即实施以下编号的改进,并同步更新架构图 004/005。
|
||||
|
||||
### 即时实施(P0 全部 + P1 关键)
|
||||
|
||||
- [x] **P0-1** `sendMessageAction` 增加 receiverId 二次校验
|
||||
- [x] **P0-2** `getMessageThread` 增加 userId 参数 + 权限校验
|
||||
- [x] **P0-3** `getMessagesPageData` 编排迁出 data-access(页面层 `Promise.all`)
|
||||
- [x] **P0-4** 删除死代码 `getMessageDetailAction`
|
||||
- [x] **P0-5** `MessageCompose` 自动保存竞态修复
|
||||
- [x] **P0-6** `parentMessageId` 校验属于当前用户会话
|
||||
- [x] **P1-1** compose 页面新增"草稿列表"区块
|
||||
- [x] **P1-2** MessageList 新增"星标" Tab
|
||||
- [x] **P1-3** MessageDetail 渲染消息线程
|
||||
- [x] **P1-4** 分页 aria-label i18n 化 + 修正语义
|
||||
- [x] **P1-6** `RecipientOption.role` 类型收紧
|
||||
- [x] **P1-7** `replyHref` 逻辑抽取为纯函数
|
||||
- [x] **P1-9** `sendMessageAction` 失败埋点
|
||||
|
||||
### 推后实施(P1-5 SSE / P1-8 依赖注入 + P2 全部)
|
||||
|
||||
P1-5(SSE 端点扩展)、P1-8(依赖注入化)、P2-1 ~ P2-11 为中长期架构演进,需独立设计与排期,本次不实施,将在下一迭代单独推进。
|
||||
|
||||
---
|
||||
|
||||
## 七、重构方案设计(针对 P0/P1 即时实施项)
|
||||
|
||||
### 7.1 P0-1:`sendMessageAction` receiverId 二次校验
|
||||
|
||||
**新增纯函数** `isReceiverAllowed(userId, receiverId, scope): Promise<boolean>`(data-access 层):
|
||||
|
||||
```typescript
|
||||
// data-access.ts 新增
|
||||
export async function isReceiverAllowed(
|
||||
userId: string,
|
||||
receiverId: string,
|
||||
scope: DataScope
|
||||
): Promise<boolean> {
|
||||
const recipients = await getRecipients(userId, scope)
|
||||
return recipients.some((r) => r.id === receiverId)
|
||||
}
|
||||
```
|
||||
|
||||
**Action 层调用**:
|
||||
|
||||
```typescript
|
||||
// actions.ts sendMessageAction
|
||||
const allowed = await isReceiverAllowed(ctx.userId, input.receiverId, ctx.dataScope)
|
||||
if (!allowed) {
|
||||
return { success: false, message: "Recipient not allowed" }
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 P0-2:`getMessageThread` 加 userId 校验
|
||||
|
||||
```typescript
|
||||
// data-access.ts
|
||||
export const getMessageThread = cache(
|
||||
async (messageId: string, userId: string): Promise<Message[]> => {
|
||||
const [root] = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(
|
||||
and(
|
||||
eq(messages.id, messageId),
|
||||
or(eq(messages.senderId, userId), eq(messages.receiverId, userId))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!root) return []
|
||||
|
||||
const replies = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.parentMessageId, messageId))
|
||||
.orderBy(desc(messages.createdAt))
|
||||
|
||||
const allRows = [root, ...replies]
|
||||
const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId]))
|
||||
return allRows.map((r) => mapMessage(r, nameMap))
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 7.3 P0-3:`getMessagesPageData` 迁出
|
||||
|
||||
```typescript
|
||||
// app/(dashboard)/messages/page.tsx
|
||||
import { getMessages } from "@/modules/messaging/data-access"
|
||||
import { getNotifications } from "@/modules/notifications/data-access"
|
||||
|
||||
const [messagesResult, notificationsResult] = await Promise.all([
|
||||
getMessages({ userId: ctx.userId, type: "all", page: 1, pageSize: 50 }),
|
||||
getNotifications(ctx.userId, { page: 1, pageSize: 20 }),
|
||||
])
|
||||
```
|
||||
|
||||
### 7.4 P0-5:`MessageCompose` 自动保存竞态修复
|
||||
|
||||
```typescript
|
||||
// message-compose.tsx
|
||||
const submittedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (submittedRef.current) return // 提交后不再自动保存
|
||||
if (!subject.trim() && !content.trim()) return
|
||||
// ... 原有逻辑
|
||||
}, [subject, content, receiverId, parentMessageId])
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
submittedRef.current = true
|
||||
// ... 原有逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 P0-6:`parentMessageId` 校验
|
||||
|
||||
```typescript
|
||||
// sendMessageAction
|
||||
if (input.parentMessageId) {
|
||||
const parent = await getMessageById(input.parentMessageId, ctx.userId)
|
||||
if (!parent) {
|
||||
return { success: false, message: "Parent message not accessible" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.6 P1-1:草稿列表 UI
|
||||
|
||||
新增 `MessageDraftList` 组件,在 compose 页面下方渲染:
|
||||
|
||||
```tsx
|
||||
// components/message-draft-list.tsx
|
||||
"use client"
|
||||
export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) {
|
||||
const t = useTranslations("messages")
|
||||
// 列表 + 继续编辑链接 + 删除按钮
|
||||
}
|
||||
```
|
||||
|
||||
compose page 调用 `getMessageDraftsAction` 获取数据并传入。
|
||||
|
||||
### 7.7 P1-2:MessageList 星标 Tab
|
||||
|
||||
```tsx
|
||||
type Tab = "inbox" | "sent" | "starred"
|
||||
|
||||
// 切换到 starred 时调用
|
||||
getMessagesAction({ type: "all", starredOnly: true })
|
||||
```
|
||||
|
||||
### 7.8 P1-3:消息线程 UI
|
||||
|
||||
```tsx
|
||||
// message-detail.tsx 内
|
||||
const [thread, setThread] = useState<Message[]>([])
|
||||
useEffect(() => {
|
||||
void getMessageThreadAction(message.id).then(setThread)
|
||||
}, [message.id])
|
||||
|
||||
// 渲染:根消息 + 回复时间线
|
||||
```
|
||||
|
||||
新增 `getMessageThreadAction` Server Action(替代直接调用 data-access,保持权限边界)。
|
||||
|
||||
### 7.9 P1-4:i18n 键新增
|
||||
|
||||
```json
|
||||
// messages.json
|
||||
"pagination": {
|
||||
"nav": "消息分页",
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"page": "第 {current} / {total} 页"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.10 P1-6:`RecipientRole` 类型
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
export type RecipientRole = "student" | "teacher" | "admin" | "parent"
|
||||
|
||||
export interface RecipientOption {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role?: RecipientRole
|
||||
}
|
||||
```
|
||||
|
||||
### 7.11 P1-7:`buildReplyHref` 纯函数
|
||||
|
||||
```typescript
|
||||
// lib/build-reply-href.ts
|
||||
export function buildReplyHref(
|
||||
message: Message,
|
||||
canSend: boolean
|
||||
): string | undefined {
|
||||
if (!canSend) return undefined
|
||||
const params = new URLSearchParams()
|
||||
params.set("parentId", message.id)
|
||||
params.set(
|
||||
"receiverId",
|
||||
message.receiverId === /* currentUserId */ "" ? message.senderId : message.receiverId
|
||||
)
|
||||
const subject = message.subject?.startsWith("Re:")
|
||||
? message.subject
|
||||
: `Re: ${message.subject ?? ""}`
|
||||
params.set("subject", subject)
|
||||
return `/messages/compose?${params.toString()}`
|
||||
}
|
||||
```
|
||||
|
||||
> 注:`buildReplyHref` 需接收 `currentUserId` 参数以判断回复方向。
|
||||
|
||||
### 7.12 P1-9:失败埋点
|
||||
|
||||
```typescript
|
||||
// actions.ts sendMessageAction catch 块
|
||||
void trackEvent({
|
||||
event: "message.send_failed",
|
||||
userId: ctx.userId,
|
||||
targetType: "message",
|
||||
properties: {
|
||||
receiverId: input.receiverId,
|
||||
reason: e instanceof Error ? e.message : "unknown",
|
||||
},
|
||||
})
|
||||
```
|
||||
900
docs/architecture/audit/permissions-audit-report.md
Normal file
900
docs/architecture/audit/permissions-audit-report.md
Normal file
@@ -0,0 +1,900 @@
|
||||
# 用户权限模块审计报告
|
||||
|
||||
> 审计范围:用户权限(RBAC)模块,包括 `shared/types/permissions.ts`、`shared/lib/permissions.ts`、`shared/lib/auth-guard.ts`、`shared/hooks/use-permission.ts`、`shared/lib/role-utils.ts`、`shared/lib/session.ts`、`auth.ts`、`modules/rbac/*`、`modules/users/*`(权限相关部分)、`app/(dashboard)/admin/{roles,permissions,users}/*`。
|
||||
> 审计时间:2026-06-24
|
||||
> 审计依据:项目规则 `.trae/rules/project_rules.md`、架构影响地图 `004/005`。
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布与行数
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|---|---|---|---|
|
||||
| shared/types | `permissions.ts` | 223 | 67 个权限点常量 + `Role`/`BuiltinRole`/`Permission`/`DataScope`/`AuthContext` 类型 |
|
||||
| shared/lib | `permissions.ts` | 301 | `ROLE_PERMISSIONS_SEED`(6 内置角色种子)+ `resolvePermissions()`(DB 查询 + 种子兜底) |
|
||||
| shared/lib | `auth-guard.ts` | 176 | `getAuthContext()` / `requirePermission()` / `checkPermission()` / `resolveDataScope()` / `requireAuth()` |
|
||||
| shared/lib | `role-utils.ts` | 34 | `normalizeRole()` / `resolvePrimaryRole()` 纯函数 |
|
||||
| shared/lib | `session.ts` | 35 | `getSession()`(动态 import `@/auth` 避免循环依赖) |
|
||||
| shared/hooks | `use-permission.ts` | 53 | 客户端 `usePermission()` Hook(`hasPermission`/`hasAnyPermission`/`hasAllPermissions`/`hasRole`) |
|
||||
| app/root | `auth.ts` | 234 | NextAuth v5 配置(Credentials + JWT + Session 回调 + 登录事件) |
|
||||
| modules/rbac | `data-access.ts` | 215 | 角色 CRUD + `getRolePermissions`/`setRolePermissions` + `ADMIN_ROLE_NAME` 常量 |
|
||||
| modules/rbac | `data-access-assignments.ts` | 161 | 用户-角色分配(`getUserRoleNames`/`assignRolesToUser`/`getUserRoleAssignments`) |
|
||||
| modules/rbac | `actions.ts` | 382 | 8 个 Server Action(含 `requirePermission` + Zod + 审计日志) |
|
||||
| modules/rbac | `schema.ts` | 42 | 4 个 Zod schema |
|
||||
| modules/rbac | `types.ts` | 56 | `RoleRecord`/`RoleWithStats`/`RoleDetail`/`CreateRoleInput`/`UpdateRoleInput`/`UserRoleAssignment`/`PaginatedResult` |
|
||||
| modules/rbac/lib | `permission-catalog.ts` | 268 | `PERMISSION_CATALOG`(24 分组)+ `getAllPermissionMetas`/`getPermissionMeta` |
|
||||
| modules/rbac/components | 7 个文件 | ~600 | `RoleList`/`RoleFormDialog`/`RolePermissionMatrix`/`PermissionCatalogView`/`UserRoleAssignDialog`/`RoleManagementView`/`RoleDetailEditButton` |
|
||||
| modules/users | `data-access.ts` | 424 | 用户查询 + `getCurrentStudentUser` + `getAdminUsers`/`getAdminUserRoles` |
|
||||
| modules/users | `actions.ts` | 218 | `updateUserProfile`/`importUsersAction`/`exportUsersAction`/`updateUserRoleAction`(空实现)/`deleteUserAction` |
|
||||
| app/(dashboard)/admin | 4 个 page.tsx | ~200 | 角色列表/角色详情/权限目录/用户管理页面 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
登录 (auth.ts authorize)
|
||||
└─▶ db.query.users → 校验密码 → 查询 usersToRoles → resolvePrimaryRole
|
||||
└─▶ JWT callback: resolvePermissions(allRoles) → 写入 token.permissions
|
||||
└─▶ Session callback: 透传到 session.user.permissions
|
||||
|
||||
请求 (Server Component / Server Action)
|
||||
└─▶ getAuthContext() → getSession() → resolveDataScope(userId, roles) → AuthContext
|
||||
└─▶ requirePermission(p) → 校验 ctx.permissions.includes(p)
|
||||
|
||||
客户端 (Client Component)
|
||||
└─▶ usePermission() → useSession() → hasPermission(p)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
架构影响地图 `004` 和数据 JSON `005` 已记录 `rbac` 模块(JSON 第 16204-16603 行),包含:
|
||||
- 8 个 actions、11 个 data-access 函数、3 个 lib 常量/函数、8 个组件
|
||||
- 依赖关系:`shared/db`、`shared/types/permissions`、`shared/lib/auth-guard`、`shared/lib/audit-logger`、`shared/lib/change-logger`
|
||||
- 被使用方:`admin/roles`、`admin/permissions`、`admin/users`、`modules/users`
|
||||
- i18n 命名空间 `rbac`,文件 `zh-CN/rbac.json` + `en/rbac.json`
|
||||
|
||||
**架构图遗漏**:
|
||||
- `shared/lib/permissions.ts`(`resolvePermissions`)未在 rbac 模块依赖中显式列出(实际被 `auth.ts` 使用)
|
||||
- `shared/lib/auth-guard.ts` 的 `resolveDataScope` 内部直接查询 `classes`/`classEnrollments`/`classSubjectTeachers`/`grades`/`parentStudentRelations` 表,属于跨模块数据访问,未在架构图中标注
|
||||
- `modules/users/data-access.ts` 的 `getCurrentStudentUser` 直接调用 `auth()` 而非 `getAuthContext()`,未在架构图中标注此异常依赖
|
||||
- `app/(dashboard)/admin/permissions/page.tsx` 直接查询 `rolePermissions`/`roles` 表,未走 data-access,架构图未标注此违规
|
||||
- `modules/users/actions.ts` 的 `deleteUserAction` 直接 `db.delete(users)`,未走 data-access,架构图未标注
|
||||
- `app/(dashboard)/admin/roles`、`admin/permissions`、`admin/users`(非 import 子目录)缺少 `loading.tsx`/`error.tsx`,架构图未标注
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 架构分层违规(P0)
|
||||
|
||||
#### 问题 2.1.1 — `app/` 直接访问数据库
|
||||
- **位置**:[admin/permissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/permissions/page.tsx) 第 28-32 行
|
||||
- **问题**:页面组件直接 `db.select(...).from(rolePermissions).innerJoin(roles, ...)` 查询数据库,绕过 data-access 层。
|
||||
- **违反规则**:`app/ 只能调用 modules/ 的 Server Actions 和 data-access,不直接访问数据库`(项目规则「架构分层规则」)。
|
||||
- **后果**:权限统计查询逻辑散落在页面中,无法复用、无法测试、无法统一加缓存;若 `rolePermissions` 表结构变更需修改多处。
|
||||
|
||||
#### 问题 2.1.2 — Server Action 直接访问数据库
|
||||
- **位置**:[users/actions.ts](file:///e:/Desktop/CICD/src/modules/users/actions.ts) 第 208 行 `await db.delete(users).where(eq(users.id, userId))`
|
||||
- **问题**:`deleteUserAction` 直接在 actions 层执行 DB 删除,未下沉到 data-access。
|
||||
- **违反规则**:`app/ 只能调用 modules/ 的 Server Actions 和 data-access` + `严格三层架构,依赖方向单向`(项目规则「架构分层规则」)。
|
||||
- **后果**:删除用户逻辑(应包含级联清理 sessions、usersToRoles、passwordSecurity 等)散落在 actions 层,易遗漏级联清理,造成孤儿数据。
|
||||
|
||||
#### 问题 2.1.3 — 客户端组件直接调用 fetch API
|
||||
- **位置**:[admin-users-view.tsx](file:///e:/Desktop/CICD/src/modules/users/components/admin-users-view.tsx) 第 120 行 `fetch("/api/admin/users/" + deleteUserId, { method: "DELETE" })`
|
||||
- **问题**:客户端组件通过 `fetch` 调用 API 路由删除用户,而非调用 Server Action。
|
||||
- **违反规则**:`app/ 只能调用 modules/ 的 Server Actions 和 data-access`(项目规则「架构分层规则」)。
|
||||
- **后果**:绕过 Server Action 的权限校验、Zod 验证、审计日志;存在未授权调用风险;删除逻辑出现两套(Server Action + API 路由)。
|
||||
|
||||
#### 问题 2.1.4 — `getCurrentStudentUser` 使用 `auth()` 而非 `getAuthContext()`
|
||||
- **位置**:[users/data-access.ts](file:///e:/Desktop/CICD/src/modules/users/data-access.ts) 第 232 行 `const session = await auth()`
|
||||
- **问题**:直接调用 `auth()` 获取 session,而非使用项目统一的 `getAuthContext()`。
|
||||
- **违反规则**:`Authentication must use getAuthContext() and getCurrentStudentUser(); avoid mixing with other auth methods`(项目记忆 Hard Constraints)。
|
||||
- **后果**:认证入口不统一,`getAuthContext` 提供的 `dataScope` 等上下文信息丢失;后续若在 session 校验逻辑中增加 IP 限制、租户隔离等,此处不会自动生效。
|
||||
|
||||
#### 问题 2.1.5 — `resolveDataScope` 跨模块直接查询数据库表
|
||||
- **位置**:[auth-guard.ts](file:///e:/Desktop/CICD/src/shared/lib/auth-guard.ts) 第 70-163 行
|
||||
- **问题**:`resolveDataScope` 直接查询 `classes`、`classEnrollments`、`classSubjectTeachers`、`grades`、`parentStudentRelations` 表,这些表属于 `school`、`classes`、`parent` 等模块。
|
||||
- **违反规则**:`模块间只能通过对方 data-access 通信,禁止跨模块直接查询数据库表`(项目规则「架构分层规则」)。
|
||||
- **后果**:`shared/lib` 反向依赖业务模块的表结构;若 `classes` 模块重构表结构,`auth-guard` 会编译报错;`shared/` 不得反向依赖业务模块的硬约束被破坏。
|
||||
|
||||
### 2.2 权限校验缺失或硬编码(P0/P1)
|
||||
|
||||
#### 问题 2.2.1 — `updateUserRoleAction` 是空实现
|
||||
- **位置**:[users/actions.ts](file:///e:/Desktop/CICD/src/modules/users/actions.ts) 第 177-196 行
|
||||
- **问题**:`updateUserRoleAction` 接收 `userId` 和 `role` 参数后,仅 `void userId; void role;` 直接返回成功,未执行任何实际操作。
|
||||
- **违反规则**:`所有 Server Action 必须调用 requirePermission() 进行权限校验`(虽已调用 `requirePermission`,但逻辑为空)+ 功能完整性。
|
||||
- **后果**:管理员在用户管理页点击「编辑」试图修改角色时,系统提示成功但实际未变更,属于功能性 Bug。
|
||||
|
||||
#### 问题 2.2.2 — `resolveDataScope` 角色硬编码
|
||||
- **位置**:[auth-guard.ts](file:///e:/Desktop/CICD/src/shared/lib/auth-guard.ts) 第 64、69、81、113、136 行
|
||||
- **问题**:使用 `roleNames.includes("admin")`、`roleNames.includes("grade_head")`、`roleNames.includes("teacher")`、`roleNames.includes("student")`、`roleNames.includes("parent")` 硬编码角色名。
|
||||
- **违反规则**:`前端权限判断统一使用 usePermission().hasPermission(),严禁出现 role === "xxx" 硬编码`(项目规则虽针对前端,但服务端 DataScope 解析也应避免硬编码角色名,以支持动态角色)。
|
||||
- **后果**:新增自定义角色无法正确解析 DataScope;`grade_head`/`teaching_head` 被合并处理,无法差异化授权。
|
||||
|
||||
#### 问题 2.2.3 — `role-utils.ts` 将 `grade_head`/`teaching_head` 折叠为 `teacher`
|
||||
- **位置**:[role-utils.ts](file:///e:/Desktop/CICD/src/shared/lib/role-utils.ts) 第 17 行
|
||||
- **问题**:`normalizeRole` 将 `grade_head`/`teaching_head` 映射为 `teacher`,丢失了角色差异。
|
||||
- **后果**:`auth.ts` JWT 回调中 `token.role = resolvePrimaryRole(allRoles)` 后,年级主任/教务主任的 `session.user.role` 变为 `teacher`,前端若依赖 `role` 字段做路由跳转会丢失差异。
|
||||
|
||||
### 2.3 国际化遗漏(P1)
|
||||
|
||||
#### 问题 2.3.1 — RBAC 组件大量硬编码英文
|
||||
- **位置**:
|
||||
- [role-list.tsx](file:///e:/Desktop/CICD/src/modules/rbac/components/role-list.tsx) 第 84、88、98、102、140、151、153、173、179、190、216、227 行("No roles yet"、"Create Role"、"Roles"、"System"、"Custom"、"Enabled"、"Disabled"、"Edit"、"Enable"、"Disable"、"Delete"、"Deleting...")
|
||||
- [role-form-dialog.tsx](file:///e:/Desktop/CICD/src/modules/rbac/components/role-form-dialog.tsx) 第 74、78-79、89、99、109、125 行("Create new role"、"Edit role"、"Role name"、"Description"、"Cancel"、"Saving...")
|
||||
- [user-role-assign-dialog.tsx](file:///e:/Desktop/CICD/src/modules/rbac/components/user-role-assign-dialog.tsx) 第 102、104-107、113、137、151、153、173 行("Assign roles"、"Save roles"、"Cancel"、"Saving...")
|
||||
- [permission-catalog-view.tsx](file:///e:/Desktop/CICD/src/modules/rbac/components/permission-catalog-view.tsx) 第 35、44 行("Permission Catalog"、"All permission points...")
|
||||
- [admin/roles/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/roles/page.tsx) 第 28-31 行
|
||||
- [admin/roles/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/roles/[id]/page.tsx) 第 53、56、63、65、78-80 行
|
||||
- [admin/permissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/permissions/page.tsx) 第 43-44 行
|
||||
- **违反规则**:`所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键`(项目规则「安全规范」+ 项目记忆 Hard Constraints)。
|
||||
- **后果**:中文用户看到英文界面,体验不一致;无法切换语言。
|
||||
|
||||
#### 问题 2.3.2 — `admin-users-view.tsx` 大量硬编码中文
|
||||
- **位置**:[admin-users-view.tsx](file:///e:/Desktop/CICD/src/modules/users/components/admin-users-view.tsx) 第 142、148、159、169、172、180、190、203、211-216、222、227、247、252、260、273、284、291、305、307、311、317 行
|
||||
- **问题**:与 2.3.1 相反,此处全部硬编码中文("用户管理"、"批量导入"、"搜索姓名或邮箱..."、"所有角色"、"搜索"、"重置"、"暂无用户"、"姓名"、"邮箱"、"角色"、"手机"、"注册时间"、"操作"、"编辑"、"分配角色"、"删除"、"确认删除用户?"等)。
|
||||
- **违反规则**:同 2.3.1。
|
||||
- **后果**:英文用户看到中文界面;与 RBAC 其他组件语言不一致(一半英文一半中文)。
|
||||
|
||||
#### 问题 2.3.3 — `rbac.json` 缺少权限点标签
|
||||
- **位置**:[zh-CN/rbac.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/rbac.json) + [en/rbac.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/en/rbac.json)
|
||||
- **问题**:`PERMISSION_CATALOG` 中每个权限点都定义了 `labelKey`(如 `rbac:permissions.exam.create`)和 `descriptionKey`,但 i18n 文件中只有 `permissions.group.*` 分组标签,缺少 `permissions.exam.create`、`permissions.exam.create.desc` 等具体权限点的翻译。
|
||||
- **后果**:`RolePermissionMatrix` 和 `PermissionCatalogView` 中 `t.has(labelKey) ? t(labelKey) : perm.key` 回退到显示原始 key(如 `EXAM_CREATE`),用户无法理解权限含义。
|
||||
|
||||
### 2.4 TypeScript 类型不安全(P1)
|
||||
|
||||
#### 问题 2.4.1 — `as` 断言违规
|
||||
- **位置**:
|
||||
- [permissions.ts](file:///e:/Desktop/CICD/src/shared/lib/permissions.ts) 第 279、296 行 `row.permission as Permission`
|
||||
- [auth-guard.ts](file:///e:/Desktop/CICD/src/shared/lib/auth-guard.ts) 第 27-28 行 `session.user.roles as Role[]`、`session.user.permissions as Permission[]`
|
||||
- [auth.ts](file:///e:/Desktop/CICD/src/auth.ts) 第 146 行 `user as { id: string; ... }`、第 196 行 `token.permissions as typeof token.permissions`、第 217-223 行 `message as { ... }`
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/rbac/data-access.ts) 第 81、186 行 `row.permission as Permission`
|
||||
- [data-access-assignments.ts](file:///e:/Desktop/CICD/src/modules/rbac/data-access-assignments.ts) 第 149 行 `params.role as string`
|
||||
- [admin/permissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/permissions/page.tsx) 第 36 行 `row.permission as Permission`
|
||||
- [users/actions.ts](file:///e:/Desktop/CICD/src/modules/users/actions.ts) 第 183-184、207 行 `formData.get(...) as string`
|
||||
- [admin-users-view.tsx](file:///e:/Desktop/CICD/src/modules/users/components/admin-users-view.tsx) 第 128 行 `e as Error`
|
||||
- **违反规则**:`禁止 as 断言(除非从 unknown 转换或测试中,需注释原因)`(项目规则「TypeScript 规则」)。
|
||||
- **后果**:类型安全被绕过,若 DB schema 或 session 结构变更,编译期不报错,运行时才暴露。
|
||||
|
||||
### 2.5 错误处理与边界缺失(P1)
|
||||
|
||||
#### 问题 2.5.1 — 缺少 `loading.tsx` / `error.tsx`
|
||||
- **位置**:
|
||||
- `app/(dashboard)/admin/roles/` — 无 `loading.tsx`、`error.tsx`
|
||||
- `app/(dashboard)/admin/roles/[id]/` — 无 `loading.tsx`、`error.tsx`
|
||||
- `app/(dashboard)/admin/permissions/` — 无 `loading.tsx`、`error.tsx`
|
||||
- `app/(dashboard)/admin/users/` — 无 `loading.tsx`、`error.tsx`(仅 `users/import/` 有)
|
||||
- **违反规则**:`All student routes must include loading.tsx and error.tsx for error boundaries`(项目记忆 Hard Constraints,虽针对 student 路由,但 admin 路由也应遵循)。
|
||||
- **后果**:页面加载时无骨架屏,体验差;`requirePermission` 抛出 `PermissionDeniedError` 时无友好错误页,显示 Next.js 默认错误。
|
||||
|
||||
#### 问题 2.5.2 — 无 React Error Boundary 包裹独立数据区块
|
||||
- **位置**:所有 RBAC 组件
|
||||
- **问题**:`RoleList`、`RolePermissionMatrix`、`PermissionCatalogView` 等组件未用 Error Boundary 包裹,单个组件抛错会导致整页崩溃。
|
||||
- **后果**:权限矩阵加载失败时,角色列表也无法显示。
|
||||
|
||||
#### 问题 2.5.3 — 无空数据/无权限/网络异常边界状态
|
||||
- **位置**:
|
||||
- `RolePermissionMatrix` 无空权限组处理
|
||||
- `UserRoleAssignDialog` 无网络异常重试
|
||||
- `PermissionCatalogView` 无加载骨架
|
||||
- **后果**:异常状态下用户看到空白或卡顿。
|
||||
|
||||
### 2.6 组件不可复用 / 逻辑未抽取(P2)
|
||||
|
||||
#### 问题 2.6.1 — `admin-users-view.tsx` 与 RBAC 组件重复实现
|
||||
- **位置**:[admin-users-view.tsx](file:///e:/Desktop/CICD/src/modules/users/components/admin-users-view.tsx)
|
||||
- **问题**:`AdminUsersView` 自行实现了删除确认对话框、分页、搜索、角色筛选,未复用 RBAC 模块的 `EmptyState`、分页组件等;同时硬编码了 `UserRoleAssignDialog` 的调用。
|
||||
- **后果**:UI 不一致,维护成本高。
|
||||
|
||||
#### 问题 2.6.2 — `admin-users-view.tsx` 包含死代码
|
||||
- **位置**:第 246-248 行「编辑」菜单项
|
||||
- **问题**:`DropdownMenuItem` 点击无任何行为,未绑定 `onClick`。
|
||||
- **后果**:用户点击「编辑」无反应,体验差。
|
||||
|
||||
#### 问题 2.6.3 — 纯逻辑与 UI 未分离
|
||||
- **位置**:`RolePermissionMatrix` 中的 `setsEqual` 函数、选中状态计算逻辑
|
||||
- **问题**:`setsEqual` 工具函数内联在组件文件底部,未抽取到 hooks 或 utils;选中状态 diff 逻辑未抽取。
|
||||
- **后果**:无法单测,无法复用。
|
||||
|
||||
### 2.7 可访问性缺失(P2)
|
||||
|
||||
#### 问题 2.7.1 — 缺少 ARIA 属性与键盘导航
|
||||
- **位置**:所有 RBAC 组件
|
||||
- **问题**:
|
||||
- `RoleList` 表格无 `<caption>` 描述
|
||||
- `RolePermissionMatrix` 的 checkbox 分组无 `fieldset`/`legend` 语义
|
||||
- `UserRoleAssignDialog` 的角色列表无 `role="list"`
|
||||
- 图标按钮(如 `MoreHorizontal` 触发器)虽有 `sr-only` 但无 `aria-label`
|
||||
- **违反规则**:`可访问性(a11y):语义化标签、ARIA 属性、键盘导航`(项目规则「企业级补充」)。
|
||||
- **后果**:屏幕阅读器用户无法理解页面结构;键盘用户操作困难。
|
||||
|
||||
### 2.8 性能与监控缺失(P2)
|
||||
|
||||
#### 问题 2.8.1 — 无 React Server Components 流式渲染
|
||||
- **位置**:所有 admin 页面
|
||||
- **问题**:页面使用 `export const dynamic = "force-dynamic"` 但未使用 `loading.tsx` 或 Suspense 流式渲染,整页阻塞等待所有数据。
|
||||
- **后果**:首屏白屏时间长。
|
||||
|
||||
#### 问题 2.8.2 — 无关键操作埋点
|
||||
- **位置**:`assignUserRolesAction`、`setRolePermissionsAction`
|
||||
- **问题**:虽有 `logAudit` 审计日志,但无前端交互埋点(如权限变更成功率、耗时、失败原因分布)。
|
||||
- **后果**:无法监控权限管理操作的健康度。
|
||||
|
||||
### 2.9 安全性隐患(P1)
|
||||
|
||||
#### 问题 2.9.1 — `deleteUserAction` 未校验目标用户是否为内置角色持有者
|
||||
- **位置**:[users/actions.ts](file:///e:/Desktop/CICD/src/modules/users/actions.ts) 第 201-217 行
|
||||
- **问题**:删除用户时未检查该用户是否为最后一个 admin 角色持有者,可能导致系统无管理员。
|
||||
- **后果**:管理员误删后无法恢复。
|
||||
|
||||
#### 问题 2.9.2 — `assignRolesToUser` 未校验角色是否为内置且未禁用
|
||||
- **位置**:[data-access-assignments.ts](file:///e:/Desktop/CICD/src/modules/rbac/data-access-assignments.ts) 第 42-73 行
|
||||
- **问题**:`assignRolesToUser` 仅校验角色名存在,未校验角色是否 `isEnabled`,可将禁用角色分配给用户(虽 `resolvePermissions` 会过滤禁用角色,但数据不一致)。
|
||||
- **后果**:用户被分配禁用角色后,UI 显示已分配但实际无权限,造成困惑。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 权限模型维度
|
||||
|
||||
| 能力 | 行业优秀实践(如 PowerSchool、Veracross、Alma) | 当前实现 | 差距影响 |
|
||||
|---|---|---|---|
|
||||
| 权限继承 | 支持权限继承(如 admin 继承 teacher 所有权限) | 扁平权限,无继承 | 新增角色需手动勾选所有权限,易遗漏 |
|
||||
| 权限模板 | 提供角色模板(如「班主任」「学科组长」)一键创建 | 无模板,从零配置 | 管理员配置成本高 |
|
||||
| 权限预览 | 保存前预览「该角色将获得哪些菜单/操作」 | 仅显示权限点列表 | 管理员无法直观理解权限效果 |
|
||||
| 权限差异对比 | 对比两个角色的权限差异 | 无 | 无法评估角色调整影响 |
|
||||
| 权限使用统计 | 统计每个权限点被多少角色/用户使用 | `PermissionCatalogView` 仅统计角色数,未统计用户数 | 无法识别「僵尸权限」 |
|
||||
| 数据范围权限 | 支持「只能看自己班级」「只能看本年级」等行级权限 | `DataScope` 已实现但硬编码角色 | 自定义角色无法配置数据范围 |
|
||||
| 权限生效时间 | 支持权限定时生效/过期 | 无 | 临时授权需手动撤销 |
|
||||
|
||||
### 3.2 UI/UX 维度
|
||||
|
||||
| 能力 | 行业优秀实践 | 当前实现 | 差距影响 |
|
||||
|---|---|---|---|
|
||||
| 权限矩阵搜索 | 支持按权限名/模块搜索过滤 | 无搜索,67 个权限点全展示 | 管理员查找特定权限困难 |
|
||||
| 权限分组折叠 | 支持折叠/展开权限分组 | 分组固定展开 | 页面过长,滚动疲劳 |
|
||||
| 批量角色分配 | 支持批量给多个用户分配角色 | 仅单个用户 | 批量入职时效率低 |
|
||||
| 角色克隆 | 复制现有角色创建新角色 | 无 | 相似角色需重新勾选 |
|
||||
| 权限变更审计可视化 | 时间轴展示角色权限变更历史 | 仅审计日志文本 | 无法直观追溯权限演进 |
|
||||
| 权限影响范围分析 | 显示「修改此角色将影响 N 个用户」 | `RoleList` 显示 userCount 但编辑时不提示 | 管理员不知修改影响范围 |
|
||||
| 实时权限校验 | 前端实时显示当前用户是否有权限操作 | `usePermission` 已实现但未在 UI 普遍使用 | 用户点击后才报无权限 |
|
||||
|
||||
### 3.3 多角色支持维度
|
||||
|
||||
| 能力 | 行业优秀实践 | 当前实现 | 差距影响 |
|
||||
|---|---|---|---|
|
||||
| 多角色叠加 | 用户多角色权限取并集 | `resolvePermissions` 已合并 | ✅ 已实现 |
|
||||
| 主角色判定 | 支持配置主角色(用于路由/默认视图) | `resolvePrimaryRole` 硬编码优先级 | 无法自定义主角色 |
|
||||
| 角色冲突检测 | 检测互斥角色(如 teacher + student) | 无 | 用户可同时拥有冲突角色 |
|
||||
| 角色有效期 | 角色分配支持起止时间 | 无 | 临时角色无法自动撤销 |
|
||||
|
||||
### 3.4 安全合规维度
|
||||
|
||||
| 能力 | 行业优秀实践 | 当前实现 | 差距影响 |
|
||||
|---|---|---|---|
|
||||
| 最小权限原则校验 | 提示「该角色权限过大」 | 无 | 易授予过度权限 |
|
||||
| 敏感权限二次确认 | 修改 admin/删除角色需二次确认 | `RoleList` 有删除确认,但改权限无 | 误操作风险 |
|
||||
| 权限变更通知 | 权限变更通知受影响用户 | 无 | 用户不知权限被调整 |
|
||||
| 权限分离审计 | 审计日志记录操作者 IP/UA | `logAudit` 未记录 IP/UA | 无法追溯操作来源 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 紧急(安全/架构违规,必须立即修复)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|---|---|---|
|
||||
| P0-1 | `admin/permissions/page.tsx` 直查 DB(问题 2.1.1) | 将权限统计查询下沉到 `modules/rbac/data-access.ts`,新增 `getPermissionRoleCounts()` 函数 |
|
||||
| P0-2 | `deleteUserAction` 直查 DB(问题 2.1.2) | 将删除逻辑下沉到 `modules/users/data-access.ts`,新增 `deleteUserById()` 并处理级联清理 |
|
||||
| P0-3 | `admin-users-view.tsx` 用 fetch 删除用户(问题 2.1.3) | 改为调用 `deleteUserAction` Server Action |
|
||||
| P0-4 | `getCurrentStudentUser` 用 `auth()`(问题 2.1.4) | 改为调用 `getAuthContext()` 获取 userId |
|
||||
| P0-5 | `updateUserRoleAction` 空实现(问题 2.2.1) | 删除此死代码,角色分配统一走 `rbac/actions.ts` 的 `assignUserRolesAction` |
|
||||
| P0-6 | 缺少 `loading.tsx`/`error.tsx`(问题 2.5.1) | 为 `admin/roles`、`admin/roles/[id]`、`admin/permissions`、`admin/users` 新增 |
|
||||
|
||||
### P1 — 高优先级(i18n/类型安全/错误处理)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|---|---|---|
|
||||
| P1-1 | RBAC 组件硬编码英文(问题 2.3.1) | 提取翻译键到 `rbac.json`,组件改用 `useTranslations` |
|
||||
| P1-2 | `admin-users-view.tsx` 硬编码中文(问题 2.3.2) | 提取翻译键到 `users.json` |
|
||||
| P1-3 | `rbac.json` 缺权限点标签(问题 2.3.3) | 补全 67 个权限点的 `label` + `desc` 翻译 |
|
||||
| P1-4 | `as` 断言违规(问题 2.4.1) | 用类型守卫替代,如 `isPermission(value): value is Permission` |
|
||||
| P1-5 | `resolveDataScope` 跨模块查表(问题 2.1.5) | 改为调用 `classes/data-access`、`parent/data-access` 等模块的查询函数 |
|
||||
| P1-6 | `resolveDataScope` 角色硬编码(问题 2.2.2) | 改为基于权限点判断(如 `hasPermission(DASHBOARD_ADMIN_READ)` → `all` scope)或配置驱动 |
|
||||
| P1-7 | 无 Error Boundary(问题 2.5.2) | 为 `RoleList`、`RolePermissionMatrix`、`PermissionCatalogView` 包裹 Error Boundary |
|
||||
| P1-8 | `deleteUserAction` 未保护最后 admin(问题 2.9.1) | 删除前校验目标用户是否为最后一个 admin |
|
||||
| P1-9 | `assignRolesToUser` 未校验角色启用状态(问题 2.9.2) | 分配前过滤 `isEnabled = false` 的角色 |
|
||||
|
||||
### P2 — 中长期(体验/性能/可扩展性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|---|---|---|
|
||||
| P2-1 | 权限矩阵无搜索/折叠 | 新增搜索框 + 分组折叠交互 |
|
||||
| P2-2 | 无角色模板 | 预置「班主任」「学科组长」等模板 |
|
||||
| P2-3 | 无权限变更影响提示 | 编辑权限时显示「将影响 N 个用户」 |
|
||||
| P2-4 | 无权限使用统计 | `PermissionCatalogView` 增加用户数统计 |
|
||||
| P2-5 | 无 a11y 属性 | 补充 ARIA、`caption`、`fieldset` |
|
||||
| P2-6 | 无 RSC 流式渲染 | 引入 Suspense + 骨架屏 |
|
||||
| P2-7 | 无关键操作埋点 | 预留 `trackPermissionChange()` 接口 |
|
||||
| P2-8 | `role-utils.ts` 折叠角色(问题 2.2.3) | 保留 `grade_head`/`teaching_head` 差异 |
|
||||
| P2-9 | 无权限继承 | 支持角色继承父角色权限 |
|
||||
| P2-10 | 无权限变更通知 | 权限变更后通知受影响用户 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图存在以下遗漏/不一致,需同步更新:
|
||||
|
||||
### 5.1 `004_architecture_impact_map.md` 需补充
|
||||
|
||||
1. **`shared/lib/auth-guard.ts` 的 `resolveDataScope` 跨模块依赖**:
|
||||
- 当前依赖 `classes`、`classEnrollments`、`classSubjectTeachers`、`grades`、`parentStudentRelations` 表
|
||||
- 应标注为「待重构:改为调用各模块 data-access」
|
||||
|
||||
2. **`modules/users/data-access.ts` 的 `getCurrentStudentUser` 异常依赖**:
|
||||
- 直接调用 `auth()` 而非 `getAuthContext()`
|
||||
- 应标注为「待修复:P0-4」
|
||||
|
||||
3. **`app/(dashboard)/admin/permissions/page.tsx` 直查 DB 违规**:
|
||||
- 应标注为「待修复:P0-1」
|
||||
|
||||
4. **`modules/users/actions.ts` 的 `deleteUserAction` 直查 DB**:
|
||||
- 应标注为「待修复:P0-2」
|
||||
|
||||
5. **缺失的 `loading.tsx`/`error.tsx`**:
|
||||
- `admin/roles`、`admin/roles/[id]`、`admin/permissions`、`admin/users` 四个路由
|
||||
|
||||
### 5.2 `005_architecture_data.json` 需补充
|
||||
|
||||
1. `modules.rbac.dependencies.dependsOn` 增加 `shared/lib/permissions`(`resolvePermissions` 被 `auth.ts` 使用,但 rbac 模块本身不直接依赖,需在 `auth` 节点标注)
|
||||
2. `modules.rbac.exports.dataAccess` 增加 `getPermissionRoleCounts`(P0-1 新增函数)
|
||||
3. `modules.users.exports.dataAccess` 增加 `deleteUserById`(P0-2 新增函数)
|
||||
4. `modules.users.exports.actions` 标注 `updateUserRoleAction` 为 deprecated/删除
|
||||
5. `app` 路由节点增加缺失的 `loading.tsx`/`error.tsx` 文件
|
||||
|
||||
---
|
||||
|
||||
## 六、重构方案设计
|
||||
|
||||
### 6.1 目标架构
|
||||
|
||||
```
|
||||
app/(dashboard)/admin/
|
||||
├─ roles/
|
||||
│ ├─ page.tsx # RSC: 调用 rbac/data-access.getRoles()
|
||||
│ ├─ loading.tsx # 骨架屏
|
||||
│ ├─ error.tsx # 错误边界
|
||||
│ └─ [id]/
|
||||
│ ├─ page.tsx # RSC: 调用 rbac/data-access.getRoleById()
|
||||
│ ├─ loading.tsx
|
||||
│ └─ error.tsx
|
||||
├─ permissions/
|
||||
│ ├─ page.tsx # RSC: 调用 rbac/data-access.getPermissionRoleCounts()
|
||||
│ ├─ loading.tsx
|
||||
│ └─ error.tsx
|
||||
└─ users/
|
||||
├─ page.tsx # RSC: 调用 users/data-access.getAdminUsers()
|
||||
├─ loading.tsx
|
||||
└─ error.tsx
|
||||
|
||||
modules/rbac/
|
||||
├─ actions.ts # Server Actions(编排层)
|
||||
├─ data-access.ts # 角色 CRUD + 权限统计
|
||||
├─ data-access-assignments.ts # 用户-角色分配
|
||||
├─ data-access-permissions.ts # 权限查询(新增 getPermissionRoleCounts)
|
||||
├─ schema.ts # Zod 验证
|
||||
├─ types.ts # 类型定义
|
||||
├─ lib/
|
||||
│ ├─ permission-catalog.ts # 权限目录常量
|
||||
│ └─ permission-diff.ts # 权限差异计算纯函数(新增)
|
||||
├─ hooks/
|
||||
│ ├─ use-role-permissions.ts # 选中状态管理 Hook(新增)
|
||||
│ └─ use-permission-search.ts # 权限搜索过滤 Hook(新增)
|
||||
└─ components/
|
||||
├─ role-list.tsx
|
||||
├─ role-form-dialog.tsx
|
||||
├─ role-permission-matrix.tsx
|
||||
├─ permission-catalog-view.tsx
|
||||
├─ user-role-assign-dialog.tsx
|
||||
├─ role-management-view.tsx
|
||||
├─ role-detail-edit-button.tsx
|
||||
├─ permission-search-bar.tsx # 新增
|
||||
├─ permission-impact-badge.tsx # 新增:显示影响用户数
|
||||
└─ error-boundary.tsx # 新增:RBAC 专用错误边界
|
||||
|
||||
modules/users/
|
||||
├─ actions.ts # 删除 updateUserRoleAction,deleteUserAction 改调 data-access
|
||||
├─ data-access.ts # 新增 deleteUserById
|
||||
└─ components/
|
||||
└─ admin-users-view.tsx # i18n 化,改用 Server Action
|
||||
|
||||
shared/lib/
|
||||
├─ auth-guard.ts # resolveDataScope 改调各模块 data-access
|
||||
├─ permissions.ts # 用类型守卫替代 as
|
||||
├─ role-utils.ts # 保留 grade_head/teaching_head 差异
|
||||
└─ type-guards.ts # 新增 isPermission/isRole 类型守卫
|
||||
```
|
||||
|
||||
### 6.2 数据服务接口抽象(解耦)
|
||||
|
||||
```typescript
|
||||
// modules/rbac/types.ts 新增
|
||||
|
||||
/** 角色数据服务接口 — 供依赖注入使用 */
|
||||
export interface RoleDataService {
|
||||
getRoles(): Promise<RoleWithStats[]>
|
||||
getRoleById(id: string): Promise<RoleDetail | null>
|
||||
createRole(input: CreateRoleInput): Promise<RoleRecord>
|
||||
updateRole(id: string, input: UpdateRoleInput): Promise<RoleRecord>
|
||||
deleteRole(id: string): Promise<void>
|
||||
setRoleEnabled(id: string, enabled: boolean): Promise<RoleRecord>
|
||||
getRolePermissions(roleId: string): Promise<Permission[]>
|
||||
setRolePermissions(roleId: string, permissions: Permission[]): Promise<void>
|
||||
getPermissionRoleCounts(): Promise<Record<string, number>>
|
||||
}
|
||||
|
||||
/** 用户-角色分配数据服务接口 */
|
||||
export interface UserRoleAssignmentService {
|
||||
getUserRoleNames(userId: string): Promise<string[]>
|
||||
assignRolesToUser(userId: string, roleNames: string[]): Promise<void>
|
||||
getUserRoleAssignments(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
role?: string
|
||||
}): Promise<PaginatedResult<UserRoleAssignment>>
|
||||
}
|
||||
|
||||
// modules/rbac/data-access.ts 实现 RoleDataService
|
||||
// modules/rbac/data-access-assignments.ts 实现 UserRoleAssignmentService
|
||||
```
|
||||
|
||||
### 6.3 组合优先的 UI 设计
|
||||
|
||||
```tsx
|
||||
// modules/rbac/components/role-permission-matrix.tsx 重构后
|
||||
|
||||
"use client"
|
||||
|
||||
import { useRolePermissions } from "../hooks/use-role-permissions"
|
||||
import { usePermissionSearch } from "../hooks/use-permission-search"
|
||||
import { PermissionSearchBar } from "./permission-search-bar"
|
||||
import { PermissionImpactBadge } from "./permission-impact-badge"
|
||||
import { ErrorBoundary } from "./error-boundary"
|
||||
|
||||
interface RolePermissionMatrixProps {
|
||||
roleId: string
|
||||
roleName: string
|
||||
currentPermissions: Permission[]
|
||||
userCount: number // 新增:用于影响提示
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
export function RolePermissionMatrix({
|
||||
roleId,
|
||||
roleName,
|
||||
currentPermissions,
|
||||
userCount,
|
||||
isLocked,
|
||||
}: RolePermissionMatrixProps) {
|
||||
const { selected, hasChanges, toggle, toggleGroup, reset } = useRolePermissions(currentPermissions)
|
||||
const { query, filteredCatalog, setQuery } = usePermissionSearch(PERMISSION_CATALOG)
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<PermissionMatrixError />}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<PermissionMatrixHeader
|
||||
roleName={roleName}
|
||||
isLocked={isLocked}
|
||||
selectedCount={selected.size}
|
||||
hasChanges={hasChanges}
|
||||
userCount={userCount}
|
||||
/>
|
||||
{!isLocked && hasChanges && (
|
||||
<PermissionImpactBadge count={userCount} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PermissionSearchBar value={query} onChange={setQuery} />
|
||||
<PermissionGroups
|
||||
catalog={filteredCatalog}
|
||||
selected={selected}
|
||||
isLocked={isLocked}
|
||||
onToggle={toggle}
|
||||
onToggleGroup={toggleGroup}
|
||||
/>
|
||||
<PermissionMatrixActions
|
||||
isLocked={isLocked}
|
||||
hasChanges={hasChanges}
|
||||
onReset={reset}
|
||||
onSave={() => handleSave(roleId, selected)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 i18n 翻译文件结构
|
||||
|
||||
```json
|
||||
// shared/i18n/messages/zh-CN/rbac.json(补全后)
|
||||
{
|
||||
"roles": { ... },
|
||||
"permissions": {
|
||||
"title": "权限目录",
|
||||
"description": "系统中定义的所有权限点,按模块分组。",
|
||||
"group": { ... },
|
||||
"exam": {
|
||||
"create": { "label": "创建考试", "desc": "允许创建新考试" },
|
||||
"read": { "label": "查看考试", "desc": "允许查看考试列表和详情" }
|
||||
// ... 67 个权限点
|
||||
}
|
||||
},
|
||||
"matrix": {
|
||||
"search": "搜索权限...",
|
||||
"selected": "已选 {count} 项",
|
||||
"impact": "修改将影响 {count} 个用户",
|
||||
"save": "保存更改",
|
||||
"reset": "重置"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载失败,请重试",
|
||||
"saveFailed": "保存失败:{message}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 错误与边界处理
|
||||
|
||||
```tsx
|
||||
// modules/rbac/components/error-boundary.tsx
|
||||
|
||||
"use client"
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? <DefaultErrorFallback error={this.state.error} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultErrorFallback({ error }: { error?: Error }): ReactNode {
|
||||
return (
|
||||
<div role="alert" className="rounded-md border border-destructive/50 p-4">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" aria-hidden="true" />
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{error?.message ?? "加载失败"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={() => window.location.reload()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/(dashboard)/admin/roles/loading.tsx
|
||||
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/(dashboard)/admin/roles/error.tsx
|
||||
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
|
||||
export default function Error({ error, reset }: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): JSX.Element {
|
||||
useEffect(() => {
|
||||
console.error("Roles page error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<ShieldAlert className="h-12 w-12 text-destructive" aria-hidden="true" />
|
||||
<h2 className="text-xl font-semibold">角色管理加载失败</h2>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
<Button onClick={reset}>重试</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 可测试性设计
|
||||
|
||||
```typescript
|
||||
// modules/rbac/lib/permission-diff.ts(纯函数,可单测)
|
||||
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
export interface PermissionDiff {
|
||||
added: Permission[]
|
||||
removed: Permission[]
|
||||
unchanged: Permission[]
|
||||
}
|
||||
|
||||
export function diffPermissions(
|
||||
before: Permission[],
|
||||
after: Permission[]
|
||||
): PermissionDiff {
|
||||
const beforeSet = new Set(before)
|
||||
const afterSet = new Set(after)
|
||||
|
||||
return {
|
||||
added: after.filter((p) => !beforeSet.has(p)),
|
||||
removed: before.filter((p) => !afterSet.has(p)),
|
||||
unchanged: after.filter((p) => beforeSet.has(p)),
|
||||
}
|
||||
}
|
||||
|
||||
export function arePermissionsEqual(
|
||||
a: Permission[],
|
||||
b: Permission[]
|
||||
): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const set = new Set(a)
|
||||
return b.every((p) => set.has(p))
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// modules/rbac/hooks/use-role-permissions.ts(纯逻辑 Hook)
|
||||
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
import { arePermissionsEqual } from "../lib/permission-diff"
|
||||
|
||||
export function useRolePermissions(initial: Permission[]) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(initial))
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => !setsEqual(selected, new Set(initial)),
|
||||
[selected, initial]
|
||||
)
|
||||
|
||||
const toggle = useCallback((permission: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(permission)
|
||||
else next.delete(permission)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleGroup = useCallback((permissions: string[], checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const p of permissions) {
|
||||
if (checked) next.add(p)
|
||||
else next.delete(p)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => setSelected(new Set(initial)), [initial])
|
||||
|
||||
return { selected, hasChanges, toggle, toggleGroup, reset }
|
||||
}
|
||||
|
||||
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const v of a) if (!b.has(v)) return false
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### 6.7 配置驱动的可扩展设计
|
||||
|
||||
```typescript
|
||||
// modules/rbac/lib/role-templates.ts(新增)
|
||||
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export interface RoleTemplate {
|
||||
id: string
|
||||
nameKey: string
|
||||
descriptionKey: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
export const ROLE_TEMPLATES: RoleTemplate[] = [
|
||||
{
|
||||
id: "homeroom_teacher",
|
||||
nameKey: "rbac:templates.homeroom_teacher.name",
|
||||
descriptionKey: "rbac:templates.homeroom_teacher.desc",
|
||||
permissions: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.ATTENDANCE_MANAGE,
|
||||
Permissions.MESSAGE_SEND,
|
||||
Permissions.DASHBOARD_TEACHER_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "subject_teacher",
|
||||
nameKey: "rbac:templates.subject_teacher.name",
|
||||
descriptionKey: "rbac:templates.subject_teacher.desc",
|
||||
permissions: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.GRADE_RECORD_MANAGE,
|
||||
],
|
||||
},
|
||||
// ... 更多模板
|
||||
]
|
||||
```
|
||||
|
||||
### 6.8 监控埋点接口
|
||||
|
||||
```typescript
|
||||
// shared/lib/analytics.ts(预留接口)
|
||||
|
||||
export interface PermissionChangeMetrics {
|
||||
action: "role.create" | "role.update" | "role.delete" | "role.set_permissions" | "user.assign_roles"
|
||||
targetId: string
|
||||
targetType: "role" | "user"
|
||||
changes?: {
|
||||
added?: number
|
||||
removed?: number
|
||||
}
|
||||
affectedUsers?: number
|
||||
durationMs: number
|
||||
success: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export async function trackPermissionChange(metrics: PermissionChangeMetrics): Promise<void> {
|
||||
// 预留实现:可接入 Sentry / PostHog / 自建埋点
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("[permission-change]", metrics)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实施计划
|
||||
|
||||
### 7.1 第一阶段(P0 — 立即修复)
|
||||
|
||||
1. **P0-1**:`admin/permissions/page.tsx` 改用 data-access
|
||||
- 在 `modules/rbac/data-access-permissions.ts` 新增 `getPermissionRoleCounts()`
|
||||
- 页面改为 `import { getPermissionRoleCounts } from "@/modules/rbac/data-access-permissions"`
|
||||
|
||||
2. **P0-2**:`deleteUserAction` 改用 data-access
|
||||
- 在 `modules/users/data-access.ts` 新增 `deleteUserById(userId: string): Promise<void>`
|
||||
- 包含级联清理 sessions、usersToRoles、passwordSecurity
|
||||
- actions.ts 改调 `deleteUserById`
|
||||
|
||||
3. **P0-3**:`admin-users-view.tsx` 改用 Server Action
|
||||
- 删除 `fetch("/api/admin/users/...")` 逻辑
|
||||
- 改用 `useActionMutation` + `deleteUserAction`
|
||||
|
||||
4. **P0-4**:`getCurrentStudentUser` 改用 `getAuthContext()`
|
||||
- `const ctx = await getAuthContext()` → `ctx.userId`
|
||||
|
||||
5. **P0-5**:删除 `updateUserRoleAction` 空实现
|
||||
|
||||
6. **P0-6**:新增 `loading.tsx`/`error.tsx`
|
||||
- 4 个路由各 2 个文件,共 8 个文件
|
||||
|
||||
### 7.2 第二阶段(P1 — i18n + 类型安全)
|
||||
|
||||
1. **P1-1/P1-2/P1-3**:i18n 化
|
||||
- 补全 `rbac.json` 的 67 个权限点标签
|
||||
- RBAC 组件改用 `useTranslations`
|
||||
- `admin-users-view.tsx` 改用 `useTranslations`
|
||||
|
||||
2. **P1-4**:类型守卫
|
||||
- `shared/lib/type-guards.ts` 新增 `isPermission(value: unknown): value is Permission`
|
||||
- 替换所有 `as Permission` 断言
|
||||
|
||||
3. **P1-5**:`resolveDataScope` 改调模块 data-access
|
||||
- `classes` 模块新增 `getClassIdsForTeacher(userId)`、`getClassIdsForStudent(userId)`
|
||||
- `parent` 模块新增 `getChildrenIdsForParent(userId)`
|
||||
- `school` 模块新增 `getGradeIdsForHead(userId)`
|
||||
|
||||
4. **P1-6**:`resolveDataScope` 改为配置驱动
|
||||
- 新增 `shared/lib/data-scope-resolver.ts`
|
||||
- 基于权限点而非角色名判断 scope
|
||||
|
||||
5. **P1-7**:Error Boundary
|
||||
- 新增 `modules/rbac/components/error-boundary.tsx`
|
||||
- 包裹 `RoleList`、`RolePermissionMatrix`、`PermissionCatalogView`
|
||||
|
||||
6. **P1-8/P1-9**:安全性增强
|
||||
- `deleteUserAction` 校验最后 admin
|
||||
- `assignRolesToUser` 过滤禁用角色
|
||||
|
||||
### 7.3 第三阶段(P2 — 体验/性能/可扩展)
|
||||
|
||||
1. **P2-1**:权限矩阵搜索 + 折叠
|
||||
2. **P2-2**:角色模板
|
||||
3. **P2-3**:权限变更影响提示
|
||||
4. **P2-4**:权限使用统计
|
||||
5. **P2-5**:a11y 增强
|
||||
6. **P2-6**:Suspense 流式渲染
|
||||
7. **P2-7**:监控埋点
|
||||
8. **P2-8**:`role-utils.ts` 保留角色差异
|
||||
9. **P2-9**:权限继承
|
||||
10. **P2-10**:权限变更通知
|
||||
|
||||
---
|
||||
|
||||
## 八、合规性检查
|
||||
|
||||
| 约束 | 当前状态 | 重构后 |
|
||||
|---|---|---|
|
||||
| 三层架构 `app → modules → shared` | ❌ app 直查 DB | ✅ 全部走 data-access |
|
||||
| `app/` 不直接访问数据库 | ❌ 3 处违规 | ✅ 修复 |
|
||||
| 模块间通过 data-access 通信 | ❌ `auth-guard` 跨模块查表 | ✅ 改调 data-access |
|
||||
| Server Action 调用 `requirePermission()` | ✅ 已实现 | ✅ 保持 |
|
||||
| 前端用 `usePermission().hasPermission()` | ✅ 已实现 | ✅ 保持 |
|
||||
| i18n 适配 | ❌ 大量硬编码 | ✅ 全部提取翻译键 |
|
||||
| TypeScript 严格模式(无 `any`/`as`) | ❌ 多处 `as` | ✅ 用类型守卫替代 |
|
||||
| 单文件行数 ≤ 500/800/1000 | ✅ 当前均未超标 | ✅ 保持 |
|
||||
| `loading.tsx`/`error.tsx` | ❌ 缺失 | ✅ 补全 |
|
||||
| 架构图同步 | ❌ 有遗漏 | ✅ 同步更新 |
|
||||
|
||||
---
|
||||
|
||||
## 九、结论
|
||||
|
||||
用户权限模块的核心基础设施(`Permissions` 常量、`resolvePermissions`、`requirePermission`、`usePermission`)设计合理,RBAC 模块的 actions/data-access 分层清晰。但存在 **6 个 P0 级架构违规**(app 直查 DB、Server Action 直查 DB、客户端 fetch、认证入口不统一、空实现 Action、缺 loading/error)、**9 个 P1 级问题**(i18n 遗漏、类型断言、跨模块查表、角色硬编码、错误边界缺失、安全隐患)和 **10 个 P2 级改进**(搜索/模板/统计/a11y/性能/监控等)。
|
||||
|
||||
建议按 P0 → P1 → P2 顺序实施,P0 必须立即修复以消除安全与架构风险,P1 在本迭代内完成,P2 纳入后续迭代规划。
|
||||
316
docs/architecture/audit/question-bank-audit-report.md
Normal file
316
docs/architecture/audit/question-bank-audit-report.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# 题库模块审计报告
|
||||
|
||||
> 审计日期:2026-06-22
|
||||
> 审计范围:`src/modules/questions/`、`src/app/(dashboard)/teacher/questions/`、共享组件 `src/shared/components/question/`
|
||||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| app | `src/app/(dashboard)/teacher/questions/page.tsx` | 129 | 教师题库列表页(Server Component) |
|
||||
| app | `src/app/(dashboard)/teacher/questions/loading.tsx` | 29 | 骨架屏 |
|
||||
| module | `src/modules/questions/actions.ts` | 158 | 5 个 Server Action(CRUD + 查询) |
|
||||
| module | `src/modules/questions/data-access.ts` | 357 | 数据访问层(含跨模块接口) |
|
||||
| module | `src/modules/questions/types.ts` | 53 | 类型定义 |
|
||||
| module | `src/modules/questions/schema.ts` | 18 | Zod 校验 |
|
||||
| module | `src/modules/questions/components/question-filters.tsx` | 104 | 筛选栏(自有实现) |
|
||||
| module | `src/modules/questions/components/question-columns.tsx` | 142 | 表格列定义 |
|
||||
| module | `src/modules/questions/components/question-data-table.tsx` | 134 | 数据表格 |
|
||||
| module | `src/modules/questions/components/create-question-button.tsx` | 20 | 创建按钮 |
|
||||
| module | `src/modules/questions/components/create-question-dialog.tsx` | 453 | 创建/编辑对话框 |
|
||||
| module | `src/modules/questions/components/question-actions.tsx` | 188 | 行操作菜单 |
|
||||
| shared | `src/shared/components/question/question-bank-filters.tsx` | 137 | 共享筛选栏(exams/lesson-prep 复用) |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
teacher/questions/page.tsx (RSC)
|
||||
├─ requirePermission(QUESTION_READ) ← 服务端权限校验
|
||||
├─ getQuestions(params) ← 直接调用 data-access(项目规则允许)
|
||||
├─ <QuestionFilters /> ← 客户端组件,调用 getKnowledgePointOptionsAction
|
||||
└─ <QuestionDataTable columns data /> ← 客户端表格
|
||||
└─ <QuestionActions question /> ← 行操作
|
||||
├─ <CreateQuestionDialog /> ← 编辑(调用 updateQuestionAction)
|
||||
└─ AlertDialog ← 删除(调用 deleteQuestionAction)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
`005_architecture_data.json` 已记录 questions 模块的 actions / dataAccess / schema / types / components,但存在以下遗漏:
|
||||
|
||||
- **依赖矩阵不完整**:`dependencyMatrix.questions.dependsOn` 仅列出 `["shared", "auth"]`,**缺失 `textbooks`**。实际 `data-access.ts:8` 导入了 `@/modules/textbooks/data-access` 的 `getKnowledgePointOptions`,属于跨模块 data-access 通信,应在依赖矩阵中记录。
|
||||
- **action 名称不一致**:架构图记录 action 名为 `createNestedQuestion`,实际代码为 `createQuestionAction`。
|
||||
- **跨模块接口未记录**:`getKnowledgePointsForQuestions`、`getQuestionsContentForErrorCollection` 两个跨模块接口未在架构图中记录。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### P0 — 严重问题
|
||||
|
||||
#### P0-1:筛选方式不符合 K12 教学场景(用户核心痛点)
|
||||
|
||||
- **位置**:`question-filters.tsx`、`page.tsx`、`data-access.ts:getQuestions`
|
||||
- **问题**:当前筛选仅支持「关键词搜索 + 题型 + 难度 + 扁平知识点下拉」。知识点下拉将所有教材的所有章节的所有知识点平铺在一个列表中,教师面对数百条选项无法快速定位。
|
||||
- **违反规则**:项目规则「交互不符合多角色使用习惯」;用户明确要求「选择一个课本 → 一个章节 → 一个课文 → 知识点」级联筛选。
|
||||
- **后果**:教师无法按教学进度精准筛选题目,题库实用性大打折扣,被迫退回手动翻找。
|
||||
|
||||
#### P0-2:完全缺失 i18n 国际化
|
||||
|
||||
- **位置**:题库模块全部文件
|
||||
- **问题**:所有用户可见文本均为硬编码英文("Question Bank"、"Add Question"、"Search questions..."、"All Types"、"Single Choice" 等),**不存在 `questions.json` 翻译文件**。
|
||||
- **违反规则**:项目规则「所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键」。
|
||||
- **后果**:无法支持中文环境,与项目其他模块(exams、textbooks、attendance 等均已 i18n)不一致。
|
||||
|
||||
#### P0-3:缺失 error.tsx 错误边界
|
||||
|
||||
- **位置**:`src/app/(dashboard)/teacher/questions/`
|
||||
- **问题**:仅有 `loading.tsx`,**无 `error.tsx`**。当 `getQuestions` 或 `requirePermission` 抛错时,错误会冒泡到上层 layout,整个仪表盘白屏。
|
||||
- **违反规则**:项目规则「所有 student routes 必须包含 loading.tsx 和 error.tsx」(此规则虽针对 student 路由,但教师路由同样应遵循);架构原则「每个独立的数据区块必须用 React Error Boundary 包裹」。
|
||||
- **后果**:单次查询失败导致整页不可用,无法恢复。
|
||||
|
||||
### P1 — 高优先级问题
|
||||
|
||||
#### P1-1:`as` 类型断言违反 TypeScript 严格规范
|
||||
|
||||
- **位置**:
|
||||
- `create-question-dialog.tsx:62` — `(content as { text?: unknown }).text`
|
||||
- `create-question-dialog.tsx:71` — `(content as { options?: unknown }).options`
|
||||
- `create-question-dialog.tsx:78` — `(opt as { id?: unknown; value?: unknown }).id`
|
||||
- `create-question-dialog.tsx:80` — `(opt as { text?: unknown; label?: unknown }).text`
|
||||
- `create-question-dialog.tsx:82` — `(opt as { isCorrect?: unknown }).isCorrect`
|
||||
- `question-columns.tsx:58` — `(content as { text?: unknown }).text`
|
||||
- `question-bank-list.tsx:108-112` — `q.content as { text?: string }`、`parsed as unknown`
|
||||
- **问题**:从 `unknown` 到具体类型使用了 `as` 断言而非类型守卫。
|
||||
- **违反规则**:项目规则「禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)」。虽然这些是从 `unknown` 转换,但未使用类型守卫函数,且未注释原因。
|
||||
- **后果**:类型不安全,运行时可能访问不存在的属性。
|
||||
|
||||
#### P1-2:筛选栏组件重复实现
|
||||
|
||||
- **位置**:`question-filters.tsx`(模块内自有实现)vs `shared/components/question/question-bank-filters.tsx`(共享组件)
|
||||
- **问题**:模块内的 `QuestionFilters` 没有复用共享 `QuestionBankFilters`,两者功能高度重叠(搜索 + 题型 + 难度),但实现完全独立。共享组件已被 exams 和 lesson-preparation 模块使用。
|
||||
- **违反规则**:项目规则「Shared components must be extracted when page duplication exceeds 90%」。
|
||||
- **后果**:维护两套筛选 UI,样式和行为不一致,修改需同步两处。
|
||||
|
||||
#### P1-3:客户端组件未做权限感知
|
||||
|
||||
- **位置**:`create-question-button.tsx`、`question-actions.tsx`
|
||||
- **问题**:`CreateQuestionButton` 无条件渲染,不检查用户是否有 `QUESTION_CREATE` 权限。`QuestionActions` 的编辑/删除按钮也不检查 `QUESTION_UPDATE`/`QUESTION_DELETE` 权限。
|
||||
- **违反规则**:项目规则「前端权限判断统一使用 `usePermission().hasPermission()`」。
|
||||
- **后果**:无权限用户看到操作按钮,点击后才在 Server Action 层被拒绝,体验差。
|
||||
|
||||
#### P1-4:题目内容 `content: unknown` 类型不安全
|
||||
|
||||
- **位置**:`types.ts:27`、`data-access.ts`、`create-question-dialog.tsx`、`question-columns.tsx`
|
||||
- **问题**:`Question.content` 类型为 `unknown`,各处用 `as` 或 `typeof` 手动解析,没有统一的类型定义和解析函数。题目内容实际有明确结构(`{ text, options?, answer?, explanation? }`),但未类型化。
|
||||
- **违反规则**:项目规则「禁止 `any`:未知类型用 `unknown` 并做类型守卫」——虽然用了 `unknown`,但缺少类型守卫。
|
||||
- **后果**:解析逻辑分散在 4+ 个文件中,重复且易错。
|
||||
|
||||
#### P1-5:无级联筛选的数据访问支持
|
||||
|
||||
- **位置**:`data-access.ts:getQuestions` 参数仅有 `knowledgePointId`,无 `textbookId`/`chapterId` 过滤
|
||||
- **问题**:要实现「课本 → 章节 → 知识点」级联筛选,需要 `getQuestions` 支持按 `textbookId`、`chapterId` 过滤,以及提供 `getTextbookOptions`、`getChaptersByTextbookId` 等级联数据接口。当前均缺失。
|
||||
- **违反规则**:架构原则「可扩展性」。
|
||||
- **后果**:无法实现用户要求的级联筛选。
|
||||
|
||||
### P2 — 中优先级问题
|
||||
|
||||
#### P2-1:仅支持教师角色,无多角色复用
|
||||
|
||||
- **位置**:仅 `src/app/(dashboard)/teacher/questions/` 存在
|
||||
- **问题**:admin 角色无法管理题库(虽然 admin 有 `QUESTION_*` 全部权限),parent/student 无法查看题目。组件未抽象为可配置化多角色复用。
|
||||
- **违反规则**:架构原则「最大化复用:识别四个角色共用的 UI 块」。
|
||||
- **后果**:新增角色需复制整个页面。
|
||||
|
||||
#### P2-2:无题目预览渲染
|
||||
|
||||
- **位置**:`question-columns.tsx:52-72`、`question-actions.tsx:160-163`
|
||||
- **问题**:题目内容预览仅截取前 80 字符的纯文本或 `JSON.stringify`,无结构化渲染(选项列表、正确答案高亮等)。
|
||||
- **违反规则**:架构原则「交互不符合多角色使用习惯」。
|
||||
- **后果**:教师无法快速判断题目内容,需逐个点开查看。
|
||||
|
||||
#### P2-3:无批量操作
|
||||
|
||||
- **位置**:`question-data-table.tsx` 有行选择 checkbox,但无批量操作 UI
|
||||
- **问题**:表格支持多选但无批量删除、批量导出、批量移动知识点等操作。
|
||||
- **违反规则**:架构原则「可扩展性」。
|
||||
- **后果**:教师需逐条操作,效率低。
|
||||
|
||||
#### P2-4:无题目导入/导出
|
||||
|
||||
- **位置**:缺失
|
||||
- **问题**:不支持从 Excel/Word 导入题目,也不支持导出。
|
||||
- **违反规则**:行业差距(见第三节)。
|
||||
- **后果**:教师无法迁移已有题库。
|
||||
|
||||
#### P2-5:create-question-dialog.tsx 接近行数上限
|
||||
|
||||
- **位置**:`create-question-dialog.tsx`(453 行)
|
||||
- **问题**:接近 500 行组件上限。包含表单、知识点选择、选项编辑三块逻辑。
|
||||
- **违反规则**:项目规则「React 组件:建议 ≤ 500 行」。
|
||||
- **后果**:维护困难,难以测试。
|
||||
|
||||
#### P2-6:无监控埋点
|
||||
|
||||
- **位置**:缺失
|
||||
- **问题**:无题目创建/删除/搜索的操作埋点接口。
|
||||
- **违反规则**:架构原则「监控:方案中预留关键操作埋点接口」。
|
||||
- **后果**:无法分析题库使用情况。
|
||||
|
||||
#### P2-7:架构图 action 名称与代码不一致
|
||||
|
||||
- **位置**:`005_architecture_data.json:4708` 记录 `createNestedQuestion`,实际代码为 `createQuestionAction`
|
||||
- **问题**:架构图与代码不同步。
|
||||
- **违反规则**:项目规则「改码必同步图」。
|
||||
- **后果**:架构图不可信。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与主流 K12 产品的差距
|
||||
|
||||
| 能力 | 主流产品(组卷网/学科网/智学网) | 当前实现 | 差距影响 |
|
||||
|------|------|------|------|
|
||||
| **级联筛选** | 教材→章节→课时→知识点四级级联 | 扁平知识点下拉 | 教师无法按教学进度选题 |
|
||||
| **题目预览** | 结构化渲染(题干+选项+答案+解析) | 截取 80 字符纯文本 | 无法快速判断题目内容 |
|
||||
| **批量操作** | 批量删除/移动/导出 | 仅有多选 UI 无操作 | 效率低 |
|
||||
| **导入导出** | Excel/Word 模板导入导出 | 无 | 无法迁移已有题库 |
|
||||
| **题目难度标签** | 易/中/难 + 星级 | 数字 1-5 | 不够直观 |
|
||||
| **题目来源标注** | 自编/教材/网络 | 仅记录 author | 无法追溯题目来源 |
|
||||
| **题目使用统计** | 被引用次数/正确率 | 仅 `childrenCount` | 无法评估题目质量 |
|
||||
| **题目版本管理** | 修改历史 | 无 | 无法回溯修改 |
|
||||
| **富文本编辑** | 公式/图片/表格 | 纯文本 textarea | 无法编辑理科题目 |
|
||||
| **多角色支持** | 教师建题/admin管理/学生练习 | 仅教师 | admin 无法管理 |
|
||||
|
||||
### 3.2 关键差距分析
|
||||
|
||||
1. **级联筛选是最大痛点**:K12 教学严格按教材章节进度推进,教师选题时必然先定位到当前教学进度对应的章节,再在该章节的知识点下筛选题目。扁平列表完全不符合此工作流。
|
||||
|
||||
2. **题目预览缺失影响效率**:教师在组卷/备课选题时,需要快速浏览多道题目的完整内容(含选项和答案),当前仅显示截断文本,必须逐个点开查看,严重影响效率。
|
||||
|
||||
3. **无导入导出限制迁移**:学校通常有存量题库(Word/Excel),无法导入意味着教师需手动重新录入所有题目,阻力极大。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0 — 立即实施
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|------|
|
||||
| P0-1 | 筛选方式不符合教学场景 | 新增「教材→章节→知识点」三级级联筛选组件,data-access 层新增 `getTextbookOptionsForQuestions`、`getChaptersByTextbookId` 接口,`getQuestions` 支持 `textbookId`/`chapterId` 参数 |
|
||||
| P0-2 | 缺失 i18n | 创建 `questions.json` 翻译文件(中/英),所有组件使用 `useTranslations("questions")` |
|
||||
| P0-3 | 缺失 error.tsx | 新增 `error.tsx` 错误边界 |
|
||||
|
||||
### P1 — 本轮实施
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|------|
|
||||
| P1-1 | `as` 类型断言 | 提取 `parseQuestionContent` 类型守卫函数到 `utils/parse-content.ts`,替换所有 `as` 断言 |
|
||||
| P1-2 | 筛选栏重复 | 模块内 `QuestionFilters` 改为复用共享 `QuestionBankFilters` + 新增级联筛选扩展 |
|
||||
| P1-3 | 客户端权限感知 | `CreateQuestionButton`、`QuestionActions` 使用 `usePermission().hasPermission()` |
|
||||
| P1-4 | content 类型不安全 | 定义 `QuestionContent` 类型,替换 `unknown` |
|
||||
| P1-5 | 无级联筛选数据支持 | data-access 新增级联数据接口 |
|
||||
|
||||
### P2 — 中长期计划
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|------|
|
||||
| P2-1 | 仅教师角色 | 抽象角色配置,新增 admin 题库页面 |
|
||||
| P2-2 | 无题目预览 | 新增 `QuestionPreview` 组件,结构化渲染 |
|
||||
| P2-3 | 无批量操作 | 新增批量操作工具栏 |
|
||||
| P2-4 | 无导入导出 | 中长期:Excel 模板导入导出 |
|
||||
| P2-5 | dialog 行数 | 拆分为 `QuestionFormFields` + `KnowledgePointSelector` + `OptionsEditor` |
|
||||
| P2-6 | 无监控埋点 | 预留 `trackQuestionEvent` 接口 |
|
||||
| P2-7 | 架构图不同步 | 同步 action 名称、依赖矩阵、跨模块接口 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
需要同步更新以下内容:
|
||||
|
||||
### 5.1 `005_architecture_data.json`
|
||||
|
||||
1. **依赖矩阵**:`dependencyMatrix.questions.dependsOn` 添加 `"textbooks"`,`uses` 添加 `textbooks: ["data-access.getKnowledgePointOptions"]`
|
||||
2. **action 名称**:`createNestedQuestion` → `createQuestionAction`
|
||||
3. **跨模块接口**:在 `dataAccess` 中补充 `getKnowledgePointsForQuestions`、`getQuestionsContentForErrorCollection`
|
||||
4. **新增 data-access 函数**:`getTextbookOptionsForQuestions`、`getChaptersForCascade`、`getQuestions` 参数新增 `textbookId`/`chapterId`
|
||||
5. **新增组件**:`QuestionCascadeFilter`、`QuestionPreview`、`QuestionContentRenderer`
|
||||
6. **新增 utils**:`utils/parse-content.ts`
|
||||
|
||||
### 5.2 `004_architecture_impact_map.md`
|
||||
|
||||
1. 题库模块章节:更新筛选方式说明(级联筛选)、新增 i18n 说明
|
||||
2. 依赖关系:补充 questions → textbooks 依赖
|
||||
3. 组件清单:新增级联筛选、预览组件
|
||||
|
||||
---
|
||||
|
||||
## 六、实施记录
|
||||
|
||||
以下改进已在本次审计中实施(含中长期计划,全部完成):
|
||||
|
||||
### P0 修复
|
||||
|
||||
- [x] P0-1:新增「教材→章节→知识点」三级级联筛选,data-access 支持 `textbookId`/`chapterId` 参数
|
||||
- [x] P0-2:创建 `questions.json` i18n 翻译文件(中/英),所有组件迁移到 `useTranslations`
|
||||
- [x] P0-3:新增 `error.tsx` 错误边界(teacher + admin)
|
||||
|
||||
### P1 修复
|
||||
|
||||
- [x] P1-1:提取 `parseQuestionContent` 类型守卫,替换所有 `as` 断言
|
||||
- [x] P1-2:模块筛选栏复用共享组件 + 级联扩展(QuestionCascadeFilter)
|
||||
- [x] P1-3:客户端组件添加 `usePermission()` 权限感知
|
||||
- [x] P1-4:定义 `QuestionContent` 类型
|
||||
- [x] P1-5:data-access 新增级联数据接口(getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter)
|
||||
|
||||
### P2 修复
|
||||
|
||||
- [x] P2-5:拆分 create-question-dialog.tsx(453→281 行,拆分 KnowledgePointSelector + OptionsEditor)
|
||||
- [x] P2-6:预留 `trackQuestionEvent` 埋点接口(no-op 实现)
|
||||
- [x] P2-7:同步架构图(004 + 005)
|
||||
|
||||
### P2 中长期计划(已全部实施)
|
||||
|
||||
- [x] P2-1:多角色支持 — 新增 `admin/questions` 页面(page.tsx + error.tsx + loading.tsx),复用全部 questions 模块组件,权限感知自动适配
|
||||
- [x] P2-2:题目结构化预览 — 新增 `QuestionContentRenderer` 组件,支持题干/选项/答案/解析结构化渲染,单选圆点/多选方框视觉区分,正确答案高亮,已集成到查看详情对话框
|
||||
- [x] P2-3:批量操作 — 新增 `deleteQuestionsBatch` data-access + `deleteQuestionsBatchAction` Server Action + `BatchOperations` 组件,表格行选择后显示批量删除工具栏,权限感知,事务保证原子性
|
||||
- [x] P2-4:导入导出 — 新增 `exportQuestions`/`importQuestions` data-access + `exportQuestionsAction`/`importQuestionsAction` Server Action + `ImportExportButtons` 组件,支持 JSON 格式导出下载和文件上传导入,权限感知,导入前预览确认
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `src/app/(dashboard)/admin/questions/page.tsx` | 136 | 管理员题库页面(复用 questions 模块组件) |
|
||||
| `src/app/(dashboard)/admin/questions/error.tsx` | 29 | 管理员题库错误边界 |
|
||||
| `src/app/(dashboard)/admin/questions/loading.tsx` | 29 | 管理员题库加载骨架屏 |
|
||||
| `src/modules/questions/components/question-content-renderer.tsx` | 110+ | 题目结构化渲染(题干/选项/答案/解析) |
|
||||
| `src/modules/questions/components/batch-operations.tsx` | 110+ | 批量操作工具栏(批量删除) |
|
||||
| `src/modules/questions/components/import-export-buttons.tsx` | 180+ | 导入导出组件(JSON 格式) |
|
||||
|
||||
### 修改文件清单
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/modules/questions/data-access.ts` | 新增 `deleteQuestionsBatch`/`exportQuestions`/`importQuestions` + 类型定义 |
|
||||
| `src/modules/questions/actions.ts` | 新增 `deleteQuestionsBatchAction`/`exportQuestionsAction`/`importQuestionsAction`,所有 Action 增加 `revalidatePath("/admin/questions")` |
|
||||
| `src/modules/questions/components/question-data-table.tsx` | 集成 BatchOperations 组件,支持 getRowId + 行选择 |
|
||||
| `src/modules/questions/components/question-bank-results-client.tsx` | 传入 getRowId 支持批量操作 |
|
||||
| `src/modules/questions/components/question-actions.tsx` | 查看详情改用 QuestionContentRenderer 结构化渲染 |
|
||||
| `src/app/(dashboard)/teacher/questions/page.tsx` | 新增 ImportExportButtons |
|
||||
| `src/app/(dashboard)/admin/questions/page.tsx` | 新增 ImportExportButtons |
|
||||
| `src/shared/i18n/messages/zh-CN/questions.json` | 新增 batch + importExport 命名空间 |
|
||||
| `src/shared/i18n/messages/en/questions.json` | 新增 batch + importExport 命名空间 |
|
||||
|
||||
### 验证结果
|
||||
|
||||
- ✅ TypeScript:questions 模块零错误(`npx tsc --noEmit` 仅余其他模块预存错误)
|
||||
- ✅ ESLint:questions 模块 + admin/questions + teacher/questions 零错误零警告(`--max-warnings=0`)
|
||||
- ✅ 架构图同步:004 + 005 已更新
|
||||
@@ -1,318 +1,274 @@
|
||||
# 学校/年级/班级管理模块审计报告
|
||||
# 学校/年级/班级管理模块审计报告 v2
|
||||
|
||||
> 审查范围:`school`(学校/学年/部门/年级 CRUD)、`grade-management`(年级管理重构模块)、`classes`(班级管理)
|
||||
> 审查日期:2026-06-22
|
||||
> 审查范围:`school`(学校/学年/部门/年级 CRUD)、`classes`(班级管理)
|
||||
> 审查日期:2026-06-22(v2 复审)
|
||||
> 审查依据:项目规则(三层架构、权限校验、i18n、TypeScript 严格模式、单文件行数限制)、K12 行业优秀实践
|
||||
> 审查方式:只读源码分析 + 架构图比对,未修改任何代码
|
||||
> 审查方式:只读源码分析 + 架构图比对 + 行业对标
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 模块文件分布
|
||||
### 1.1 v1 审计修复回顾
|
||||
|
||||
| 模块 | 核心文件 | 行数(约) | 职责 |
|
||||
|------|---------|-----------|------|
|
||||
| `school` | `actions.ts` / `data-access.ts` / `schema.ts` / `types.ts` + 4 个组件 | 349 / 504 / 51 / 96 | 学校/学年/部门/年级的 CRUD |
|
||||
| `grade-management` | `actions.ts` / `data-access.ts` / `data-access-insights.ts` / `schema.ts` / `types.ts` + 11 组件 + 4 hooks + 4 services + 2 widgets + 1 config | 213 / 238 / 75 / — / 149 | 年级管理(重构版,含洞察) |
|
||||
| `classes` | `actions.ts` / `data-access.ts` / `data-access-{admin,stats,schedule,students,invitations}.ts` / `schema.ts` / `types.ts` + 14 组件 | 974 / 548 / 406 / 513 / 194 / 253 / — / 152 / 183 | 班级 CRUD + 学生/教师管理 + 邀请码 + 课表 + 作业洞察 |
|
||||
v1 审计报告识别了 14 个问题(5 个 P0 + 6 个 P1 + 5 个 P2),截至本次复审,**全部 14 个问题已修复**:
|
||||
|
||||
### 1.2 页面分布(共 13 个 page.tsx)
|
||||
| 编号 | 问题 | 状态 |
|
||||
|------|------|------|
|
||||
| P0-1 | `grade-management` 死模块 | ✅ 已删除 |
|
||||
| P0-2 | 年级 CRUD 逻辑重复 | ✅ 统一到 school 模块 |
|
||||
| P0-3 | `classes/actions.ts` 974 行 | ✅ 拆分为 6 个文件 |
|
||||
| P0-4 | `teacher/classes/*` 无权限校验 | ✅ 4 个页面已加 `requirePermission` |
|
||||
| P0-5 | school 模块无 i18n | ✅ 4 个组件已接入 `useTranslations` |
|
||||
| P1-1 | 角色硬编码 | ✅ 改为 `dataScope.type` 判断 |
|
||||
| P1-2 | school 无 i18n 文件 | ✅ `school.json` 已创建(413 行翻译键) |
|
||||
| P1-3 | school 缺 Error Boundary/Skeleton | ✅ 已补充 |
|
||||
| P1-4 | classes/types.ts 跨领域类型 | ✅ 保留并加注释说明 |
|
||||
| P1-5 | school 未用组合模式 | ✅ 拆分为 4 个子组件 |
|
||||
| P1-6 | data-access 无权限过滤 | ✅ 新增 `getSchoolsForUser`/`getGradesForUser` |
|
||||
| P2-1~P2-5 | 各项优化 | ✅ 全部修复 |
|
||||
|
||||
| 路由分组 | 页面 | 权限校验 | i18n | 调用方式 |
|
||||
|---------|------|---------|------|---------|
|
||||
| `admin/school/*` | schools / grades / classes / departments / academic-year | ✅ `requirePermission(SCHOOL_MANAGE)` | ❌ 中文硬编码 | 直接调用 data-access |
|
||||
| `management/grade/*` | classes / insights | ✅ `requirePermission(GRADE_MANAGE/GRADE_RECORD_READ)` | ❌ 英文硬编码 | 直接调用 classes/school data-access |
|
||||
| `teacher/classes/*` | my / my/[id] / schedule / students | ❌ **无任何校验** | ❌ 英文硬编码 | 直接调用 data-access |
|
||||
### 1.2 当前模块文件分布
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
| 模块 | 核心文件 | 行数 | 职责 |
|
||||
|------|---------|------|------|
|
||||
| `school` | `actions.ts` | 457 | 13 个 Server Action(含 `promoteGradesAction`) |
|
||||
| `school` | `data-access.ts` | 757 | 只读查询 + 12 个写操作 + 组织树 + 权限感知函数 |
|
||||
| `school` | `schema.ts` / `types.ts` | 51 / 90 | Zod 校验 / 类型定义 |
|
||||
| `school` | `components/` (14 文件) | 60~860 | 学校/学年/部门/年级视图 + 仪表盘 + 组织树 |
|
||||
| `school` | `hooks/use-school-data.ts` | 36 | 学校数据管理 hook |
|
||||
| `classes` | `actions.ts` (barrel) | 51 | re-export 入口 |
|
||||
| `classes` | `actions-{teacher,admin,grade,invitations,schedule,shared}.ts` | 60~448 | 按职责拆分的 6 个 Action 文件 |
|
||||
| `classes` | `data-access.ts` | 833 | 核心班级 CRUD + 邀请码 + 教师班级管理 |
|
||||
| `classes` | `data-access-{admin,stats,schedule,students,invitations}.ts` | 93~454 | 按领域拆分的 5 个 data-access |
|
||||
| `classes` | `schema.ts` / `types.ts` | 152 / 177 | Zod 校验 / 类型定义 |
|
||||
| `classes` | `components/` (14 文件) | 137~499 | 班级列表/详情/学生/课表/邀请码 |
|
||||
|
||||
`docs/architecture/004_architecture_impact_map.md` 中:
|
||||
- ✅ 已记录 `school` 模块(§2.8)和 `classes` 模块(§2.7)的职责、依赖关系、跨模块通信方式
|
||||
- ✅ 已记录 `classes` 模块的文件拆分(5 个 data-access 子文件)和 P0-7 修复(homework 跨模块封装)
|
||||
- ❌ **未记录 `grade-management` 模块** — 该模块拥有完整的 services/hooks/widgets/config 架构,但架构图中完全缺失
|
||||
- ❌ **未记录 `grade-management` 模块未被任何页面使用的事实** — 这是重大架构偏差
|
||||
### 1.3 页面分布(共 13 个 page.tsx)
|
||||
|
||||
| 路由分组 | 页面 | 权限校验 | i18n | Error Boundary | Loading |
|
||||
|---------|------|---------|------|----------------|---------|
|
||||
| `admin/school/schools` | ✅ `SCHOOL_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `admin/school/grades` | ✅ `SCHOOL_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `admin/school/grades/insights` | ✅ `SCHOOL_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `admin/school/departments` | ✅ `SCHOOL_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `admin/school/academic-year` | ✅ `SCHOOL_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `admin/school/classes` | ✅ `SCHOOL_MANAGE` | ❌ | ❌ | ✅ | ✅ |
|
||||
| `management/grade/classes` | ✅ `GRADE_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `management/grade/insights` | ✅ `GRADE_MANAGE` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `management/grade/dashboard` | ✅ `GRADE_MANAGE` | ✅ | ✅ | ❌ | ✅ |
|
||||
| `teacher/classes/my` | ✅ `CLASS_READ` | ❌ | ❌ | ❌ | ✅ |
|
||||
| `teacher/classes/my/[id]` | ✅ `CLASS_READ` | ❌ | ❌ | ❌ | ❌ |
|
||||
| `teacher/classes/schedule` | ✅ `CLASS_READ` | ❌ | ✅(Suspense) | ❌ | ✅ |
|
||||
| `teacher/classes/students` | ✅ `CLASS_READ` | ❌ | ✅(Suspense) | ❌ | ✅ |
|
||||
|
||||
### 1.4 数据流概要
|
||||
|
||||
```
|
||||
admin/school/grades 页面
|
||||
└─→ school/data-access.getGrades() / getSchools() / getStaffOptions()
|
||||
└─→ school/components/grades-view.tsx(客户端组件)
|
||||
└─→ school/actions.ts → createGradeAction / updateGradeAction / deleteGradeAction
|
||||
admin/school/* 页面(✅ 完整 i18n + Error Boundary + Skeleton)
|
||||
└─→ school/data-access.getSchools() / getGrades() / getStaffOptions() / getGradeOverviewStats()
|
||||
└─→ school/components/*(✅ 全部使用 useTranslations)
|
||||
└─→ school/actions.ts → createXxxAction / updateXxxAction / deleteXxxAction
|
||||
|
||||
management/grade/classes 页面
|
||||
management/grade/* 页面(✅ 完整 i18n + Error Boundary)
|
||||
└─→ classes/data-access.getGradeManagedClasses() / getTeacherOptions()
|
||||
└─→ school/data-access.getGradesForStaff()
|
||||
└─→ classes/components/grade-classes-view.tsx
|
||||
└─→ classes/actions.ts → createGradeClassAction / updateGradeClassAction / ...
|
||||
└─→ classes/components/grade-classes-view.tsx(❌ 无 i18n)
|
||||
└─→ classes/actions.ts → createGradeClassAction / ...
|
||||
|
||||
teacher/classes/* 页面
|
||||
teacher/classes/* 页面(❌ 组件无 i18n,❌ 无 Error Boundary)
|
||||
└─→ classes/data-access.getTeacherClasses() / getClassStudents() / getClassSchedule()
|
||||
└─→ classes/components/*(my-classes-grid / students-table / schedule-view)
|
||||
└─→ classes/components/*(❌ 14 个组件中仅 1 个使用 useTranslations)
|
||||
└─→ classes/actions.ts → createTeacherClassAction / ...
|
||||
|
||||
grade-management 模块(⚠️ 完全未被使用)
|
||||
└─→ services/grade-service.ts(接口定义)
|
||||
└─→ services/admin-grade-service.ts / teacher-grade-service.ts(实现)
|
||||
└─→ widgets/grade-management-widget.tsx(主面板)
|
||||
└─→ ⚠️ 无任何页面导入此模块
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
## 二、现存问题与原因分析(v2 新发现)
|
||||
|
||||
### 2.1 架构层面
|
||||
### 2.1 国际化层面
|
||||
|
||||
#### P0-1:`grade-management` 模块完全未被使用(死模块)
|
||||
|
||||
- **位置**:`src/modules/grade-management/` 全模块
|
||||
- **问题**:该模块拥有完整的理想架构(Service 接口 + Context 依赖注入 + 角色配置 + Error Boundary + Skeleton + i18n + hooks 分离),但 **13 个相关页面中无任何一个导入该模块**。`management/grade/*` 页面实际依赖 `classes` 和 `school` 模块的 data-access。
|
||||
- **违反规则**:架构图优先规则(图未覆盖则先补图)、模块标准结构(该模块存在但未接入)
|
||||
- **原因**:推测为未完成的重构 — 已建立目标架构但未将页面迁移过来
|
||||
- **后果**:维护两套年级管理逻辑(`school` 模块的 grade CRUD + `grade-management` 模块的 grade CRUD),职责重叠、产生混淆;理想架构模式无法落地发挥价值
|
||||
|
||||
#### P0-2:年级 CRUD 逻辑重复定义
|
||||
#### P0-A:classes 模块 13/14 个组件未接入 i18n
|
||||
|
||||
- **位置**:
|
||||
- `src/modules/school/actions.ts` L268-349:`createGradeAction` / `updateGradeAction` / `deleteGradeAction`
|
||||
- `src/modules/grade-management/actions.ts` L37-203:同名函数 `createGradeAction` / `updateGradeAction` / `deleteGradeAction`
|
||||
- `src/modules/school/data-access.ts` L256-285:`createGrade` / `updateGrade` / `deleteGrade`
|
||||
- `src/modules/grade-management/data-access.ts` L137-171:同名函数 `createGrade` / `updateGrade` / `deleteGrade`
|
||||
- **问题**:两套模块各自定义了完全相同的年级 CRUD 逻辑,`admin/school/grades` 页面使用 `school` 模块版本,`grade-management` 模块版本无人调用
|
||||
- **违反规则**:DRY 原则、模块标准结构(职责应归属单一模块)
|
||||
- **后果**:修改年级逻辑需同步两处,极易遗漏;两套实现的审计日志策略不一致(school 模块 grade CRUD 无 `logAudit`,grade-management 模块有)
|
||||
|
||||
#### P0-3:`classes/actions.ts` 接近行数硬上限
|
||||
|
||||
- **位置**:`src/modules/classes/actions.ts`(974 行)
|
||||
- **问题**:文件已达 974 行,接近 1000 行硬性上限。包含 3 组近乎重复的 CRUD Action(Teacher 系列 / Admin 系列 / Grade 系列)+ 邀请码 Action + 课表 Action
|
||||
- **违反规则**:单文件行数限制(Server Actions 建议 ≤ 800 行,硬性上限 1000 行)
|
||||
- **后果**:再增加任何功能即超限;文件过大降低可读性和可维护性
|
||||
|
||||
### 2.2 权限层面
|
||||
|
||||
#### P0-4:`teacher/classes/*` 4 个页面完全缺少权限校验
|
||||
|
||||
- **位置**:
|
||||
- `src/app/(dashboard)/teacher/classes/my/page.tsx` — 无 `requirePermission()`
|
||||
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` — 无 `requirePermission()`
|
||||
- `src/app/(dashboard)/teacher/classes/schedule/page.tsx` — 无 `requirePermission()`
|
||||
- `src/app/(dashboard)/teacher/classes/students/page.tsx` — 无 `requirePermission()`
|
||||
- **问题**:这 4 个业务页面直接调用 data-access 获取数据,依赖路由中间件隐式保障身份,无显式权限校验
|
||||
- **违反规则**:Server Action 规范(每个 Action 必须调用 `requirePermission()`);安全性规范(所有敏感数据查询必须在 data-access 层结合当前用户权限过滤)
|
||||
- **后果**:若路由中间件配置错误或被绕过,教师可访问任意班级数据;data-access 层的 `getTeacherClasses()` 未接收 userId 参数做范围过滤
|
||||
|
||||
#### P1-1:`classes/actions.ts` 中存在 `ctx.roles.includes("xxx")` 硬编码
|
||||
|
||||
- **位置**:`src/modules/classes/actions.ts` L81、L420、L422、L428、L446、L451
|
||||
- **问题**:Server Action 中使用 `ctx.roles.includes("admin")` / `ctx.roles.includes("teacher")` / `ctx.roles.includes("student")` 进行角色判断
|
||||
- **违反规则**:前端组件禁止 `role === "xxx"` 硬编码(虽此处在 Server Action 而非前端组件,但精神一致 — 应使用权限点而非角色名)
|
||||
- **后果**:新增角色(如 grade_head)需修改所有硬编码处;角色与权限耦合,不符合权限点驱动设计
|
||||
|
||||
### 2.3 国际化层面
|
||||
|
||||
#### P0-5:全部 13 个页面均未使用 i18n
|
||||
|
||||
- **位置**:所有 13 个 page.tsx 及其引用的组件
|
||||
- **问题**:
|
||||
- `admin/school/*` 页面使用**中文硬编码**(如 "学校管理"、"年级管理"、"班级管理")
|
||||
- `management/grade/*` 和 `teacher/classes/*` 页面使用**英文硬编码**(如 "Class Management"、"Grade Insights")
|
||||
- `school/components/*` 全部使用英文硬编码(如 "New school"、"All schools"、"Edit"、"Delete")
|
||||
- `classes/components/*` 混用中英文
|
||||
- `src/modules/classes/components/admin-classes-view.tsx` — 16 处英文硬编码("Failed to create class"、"No classes"、"Select a school" 等)
|
||||
- `src/modules/classes/components/grade-classes-view.tsx` — 20+ 处英文硬编码("Homeroom Teacher"、"Subject Teachers"、"Select a grade" 等)
|
||||
- `src/modules/classes/components/my-classes-grid.tsx` — 15+ 处英文硬编码("Join New Class"、"Invitation Code"、"Cancel" 等)+ 1 处中文硬编码("教学科目"、"暂无可选科目")
|
||||
- `src/modules/classes/components/schedule-view.tsx` — 10+ 处英文硬编码
|
||||
- `src/modules/classes/components/schedule-filters.tsx` — 8 处英文硬编码
|
||||
- `src/modules/classes/components/students-filters.tsx` — 5 处英文硬编码
|
||||
- `src/modules/classes/components/students-table.tsx` — 英文硬编码
|
||||
- `src/modules/classes/components/class-detail/*.tsx`(7 个文件)— 全部英文硬编码("Recent Homework"、"Weekly Schedule"、"Quick Actions" 等)
|
||||
- **问题**:14 个组件中仅 `class-invitation-manager.tsx` 使用 `useTranslations`,其余 13 个组件全部硬编码文本,且 `my-classes-grid.tsx` 中存在中英文混用
|
||||
- **违反规则**:所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键
|
||||
- **后果**:无法支持多语言;中英文混用严重影响一致性和专业度;i18n 资源文件(`grade.json`、`classes.json`)已存在但未被使用
|
||||
- **原因**:v1 审计仅修复了 school 模块的 i18n,classes 模块的 i18n 遗留未处理
|
||||
- **后果**:teacher/management 视角下的班级管理页面无法支持多语言;中英文混用严重影响专业度
|
||||
|
||||
#### P1-2:`school` 模块无 i18n 资源文件
|
||||
#### P0-B:classes.json i18n 文件内容严重不足
|
||||
|
||||
- **位置**:`src/shared/i18n/messages/{zh-CN,en}/` 目录
|
||||
- **问题**:存在 `grade.json`、`classes.json`,但**不存在 `school.json`**。school 模块的学校/学年/部门管理文本无翻译键可用
|
||||
- **位置**:`src/shared/i18n/messages/{zh-CN,en}/classes.json`
|
||||
- **问题**:当前仅 55 行,只覆盖 `invitation.*` 和 `class.*`(5 个基础字段)。缺少班级 CRUD 表单、列表、筛选器、详情页、学生管理、课表等全部场景的翻译键
|
||||
- **违反规则**:i18n 就绪规范
|
||||
- **后果**:即使想为 school 模块补充 i18n,也缺少翻译文件基础设施
|
||||
- **后果**:即使组件想接入 i18n,也缺少翻译键可用
|
||||
|
||||
### 2.4 组件质量层面
|
||||
### 2.2 文件大小层面
|
||||
|
||||
#### P1-3:`school/components/*` 缺少 Error Boundary 和 Skeleton
|
||||
#### P1-A:`grades-view.tsx` 860 行超出组件行数限制
|
||||
|
||||
- **位置**:`src/modules/school/components/schools-view.tsx` / `grades-view.tsx` / `departments-view.tsx` / `academic-year-view.tsx`
|
||||
- **问题**:4 个组件均为 `"use client"` 客户端组件,无 Error Boundary 包裹、无加载骨架屏、无 Suspense 处理。对比 `grade-management` 模块已有 `grade-error-boundary.tsx` / `grade-skeleton.tsx` / `grade-states.tsx`(但未被使用)
|
||||
- **位置**:`src/modules/school/components/grades-view.tsx`(860 行)
|
||||
- **问题**:单个客户端组件文件 860 行,超过 React 组件建议上限 500 行(复杂表单可放宽至 800 行,但仍超)。包含年级列表 + 概览卡片 + 筛选器 + 创建/编辑表单 + 删除对话框 + 年级升级对话框全部逻辑
|
||||
- **违反规则**:单文件行数限制(React 组件建议 ≤ 500 行,复杂表单/大型表格可放宽至 800 行)
|
||||
- **后果**:可读性差,难以维护;筛选逻辑、表单校验逻辑、对话框状态管理全部耦合
|
||||
|
||||
#### P1-B:`classes/data-access.ts` 833 行超出 data-access 限制
|
||||
|
||||
- **位置**:`src/modules/classes/data-access.ts`(833 行)
|
||||
- **问题**:虽然已拆分为 5 个 data-access 子文件,但主 `data-access.ts` 仍达 833 行,超过 data-access 建议 800 行上限。包含核心班级 CRUD + 邀请码 + 教师班级管理 + 跨模块接口
|
||||
- **违反规则**:单文件行数限制(Server Actions / Data Access 模块建议 ≤ 800 行)
|
||||
- **后果**:接近硬上限,后续增加功能即超限
|
||||
|
||||
### 2.3 错误处理层面
|
||||
|
||||
#### P1-C:classes 模块组件缺少 Error Boundary 和 Skeleton
|
||||
|
||||
- **位置**:
|
||||
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` — 无 Error Boundary
|
||||
- `src/app/(dashboard)/admin/school/classes/page.tsx` — 无 Error Boundary
|
||||
- `src/app/(dashboard)/management/grade/dashboard/page.tsx` — 无 Error Boundary
|
||||
- `src/modules/classes/components/*` — 无骨架屏组件
|
||||
- **问题**:对比 school 模块已有 `school-error-boundary.tsx` + `school-skeleton.tsx`,classes 模块完全没有错误边界和骨架屏组件
|
||||
- **违反规则**:错误与边界处理(每个独立数据区块必须用 React Error Boundary 包裹;异步数据使用 React Suspense + 骨架屏)
|
||||
- **后果**:数据加载失败时整页崩溃无降级;加载过程无反馈
|
||||
|
||||
#### P1-4:`classes/types.ts` 跨领域类型污染
|
||||
### 2.4 组件质量层面
|
||||
|
||||
- **位置**:`src/modules/classes/types.ts`
|
||||
- **问题**:定义了本应属于其他模块的类型:
|
||||
- `ClassHomeworkInsights` / `GradeHomeworkInsights` / `ClassHomeworkAssignmentStats` / `ScoreStats` / `AssignmentSummary` — 应属 homework 模块
|
||||
- `ClassScheduleItem` / `StudentScheduleItem` — 与 scheduling 模块概念重叠
|
||||
- **违反规则**:模块标准结构(类型应归属对应模块)
|
||||
- **后果**:classes 模块承担了 homework/scheduling 的类型定义职责,耦合度高
|
||||
|
||||
#### P1-5:`school/components/*` 未使用组合模式
|
||||
|
||||
- **位置**:`src/modules/school/components/schools-view.tsx`
|
||||
- **问题**:`SchoolsClient` 组件内部硬编码了 Table + Dialog + AlertDialog 的完整结构,无法通过 slots/render props 定制。对比 `grade-management` 模块的 `GradeManagementWidget` 通过组合 `GradeListTable` + `GradeListToolbar` + `GradeFormDialog` + `GradeDeleteDialog` 实现灵活性
|
||||
- **违反规则**:组合优先(所有 UI 通过组件组合实现灵活性)
|
||||
- **后果**:无法复用表格/对话框子部件;新增角色差异需复制整个组件
|
||||
|
||||
### 2.5 数据安全层面
|
||||
|
||||
#### P1-6:data-access 层部分查询未结合用户权限过滤
|
||||
#### P1-D:classes 组件未使用组合模式
|
||||
|
||||
- **位置**:
|
||||
- `src/modules/classes/data-access.ts` — `getTeacherClasses()` 未接收 userId 参数
|
||||
- `src/modules/school/data-access.ts` — `getGrades()` / `getSchools()` 返回全量数据,无权限过滤
|
||||
- **问题**:data-access 函数为全局查询,不结合当前用户身份做数据范围过滤,完全依赖 actions 层或页面层校验
|
||||
- **违反规则**:安全性规范(所有敏感数据查询必须在 data-access 层结合当前用户权限过滤)
|
||||
- **后果**:若上层遗漏校验(如 P0-4 中 teacher/classes 页面),数据越权访问风险
|
||||
- `src/modules/classes/components/admin-classes-view.tsx`(499 行)— 单体组件包含 Table + Dialog + AlertDialog + Select 全部逻辑
|
||||
- `src/modules/classes/components/grade-classes-view.tsx`(408 行)— 同上
|
||||
- `src/modules/classes/components/my-classes-grid.tsx`(390 行)— 单体组件包含卡片网格 + 加入班级对话框 + 邀请码管理
|
||||
- **问题**:对比 school 模块已拆分为 `SchoolListToolbar` + `SchoolFormDialog` + `SchoolDeleteDialog` + `useSchoolData` hook,classes 模块仍是单体组件,无法复用子部件
|
||||
- **违反规则**:组合优先(所有 UI 通过组件组合实现灵活性)
|
||||
- **后果**:admin/grade/teacher 三个视角的班级管理存在大量重复代码(表单、对话框、筛选器),无法复用
|
||||
|
||||
### 2.6 可测试性层面
|
||||
#### P1-E:classes 模块缺少 hooks 抽取
|
||||
|
||||
#### P2-1:`school` 和 `classes` 模块逻辑与 UI 耦合,难以单测
|
||||
|
||||
- **位置**:`school/components/*` / `classes/components/*`
|
||||
- **问题**:组件内部直接调用 actions、管理状态、处理错误,未将数据获取/计算/格式化逻辑抽取为独立 hooks 或纯函数。对比 `grade-management` 模块已抽取 `use-grade-data` / `use-grade-filters` / `use-grade-form` / `use-grade-insights` 四个 hooks
|
||||
- **位置**:`src/modules/classes/` — 无 `hooks/` 目录
|
||||
- **问题**:对比 school 模块已抽取 `use-school-data` hook,classes 模块的对话框状态管理、表单校验、筛选逻辑全部耦合在组件内部
|
||||
- **违反规则**:可测试性(数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离)
|
||||
- **后果**:无法对筛选逻辑、表单校验逻辑进行独立单测
|
||||
|
||||
### 2.5 架构模式层面
|
||||
|
||||
#### P2-A:缺少 Service 接口抽象和依赖注入
|
||||
|
||||
- **位置**:整个 school + classes 模块
|
||||
- **问题**:组件直接 import data-access 函数,未通过 Service 接口抽象数据依赖。对比已删除的 `grade-management` 模块曾有完整的 `GradeService` 接口 + Context DI 模式
|
||||
- **违反规则**:完全解耦(通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务)
|
||||
- **后果**:组件与 data-access 直接耦合,难以 mock 测试;不同角色的差异通过 if/else 硬编码而非接口实现隔离
|
||||
|
||||
#### P2-B:缺少角色配置驱动设计
|
||||
|
||||
- **位置**:整个 school + classes 模块
|
||||
- **问题**:admin/teacher/grade 三个视角的班级管理通过 3 套独立的组件实现(`admin-classes-view` / `grade-classes-view` / `my-classes-grid`),而非通过配置驱动同一套组件
|
||||
- **违反规则**:可扩展性(采用配置驱动设计,通过角色配置决定渲染哪些 Widget)
|
||||
- **后果**:新增角色需复制整套组件;三套组件存在大量重复代码
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与优秀 K12 产品的差距
|
||||
### 3.1 与优秀 K12 产品的差距(v2 更新)
|
||||
|
||||
| 功能/交互 | 行业优秀实践(Google Classroom / 钉钉教育 / 智学网) | 当前状态 | 影响 |
|
||||
|----------|------------------------------------------------------|---------|------|
|
||||
| **学校切换** | 顶部全局学校切换器,切换后所有页面数据联动 | 仅 admin 跨校可见,无全局切换器 | 多校区场景下教师/学生无法快速切换视角 |
|
||||
| **年级→班级树形导航** | 左侧树形结构(学校→年级→班级),支持展开/折叠/搜索 | 扁平列表,无层级导航 | 班级数量多时查找效率低 |
|
||||
| **班级详情仪表盘** | 一页聚合:基本信息 + 学生名单 + 课表 + 作业 + 成绩趋势 | `teacher/classes/my/[id]` 已有 class-detail 子组件,但 admin/grade 视角无详情页 | admin/年级组长无法下钻查看班级详情 |
|
||||
| **批量操作** | 批量导入学生、批量分配教师、批量升级班级 | 仅支持单条 CRUD + 邮箱注册 | 开学季配置效率低 |
|
||||
| **空状态引导** | 空状态带引导按钮和说明文案 | schools-view 有 EmptyState,其他组件不一致 | 新用户不知道下一步该做什么 |
|
||||
| **加载骨架屏** | 数据加载时显示骨架屏保持布局稳定 | school/classes 组件无骨架屏(grade-management 有但未使用) | 加载过程布局跳动,体验差 |
|
||||
| **邀请码加入** | 二维码 + 链接 + 6 位码三种方式 | 仅 6 位码(v3 已支持有效期/次数) | 家长端操作门槛略高 |
|
||||
| **年级升级** | 学年末一键升级(三年级→四年级),保留历史档案 | 无此功能 | 每年需手动重建班级 |
|
||||
| **数据权限隔离** | 教师仅看到自己班级,年级组长看到年级所有班级,admin 看到全部 | teacher/classes 页面无权限校验(P0-4),data-access 无范围过滤(P1-6) | 存在越权风险 |
|
||||
| 功能/交互 | 行业优秀实践 | 当前状态 | 影响 |
|
||||
|----------|------------|---------|------|
|
||||
| **i18n 完整覆盖** | 所有角色所有页面支持多语言 | school 模块 ✅,classes 模块 ❌(13/14 组件硬编码) | classes 模块无法支持多语言 |
|
||||
| **错误边界** | 每个数据区块独立 Error Boundary | school 模块 ✅,classes 模块 ❌ | classes 数据加载失败整页崩溃 |
|
||||
| **骨架屏** | 加载时保持布局稳定 | school 模块 ✅,classes 模块 ❌ | classes 加载过程布局跳动 |
|
||||
| **组件复用** | 三个角色共用一套可配置组件 | 三套独立组件(admin/grade/teacher) | 代码重复,维护成本高 |
|
||||
| **班级详情仪表盘** | 一页聚合基本信息 + 学生 + 课表 + 作业 + 成绩 | `teacher/classes/my/[id]` 已有,但 admin/grade 视角无 | admin/年级组长无法下钻 |
|
||||
| **批量操作** | 批量导入学生、批量分配教师 | ✅ 已实现(v1 P2-4 修复) | 已达标 |
|
||||
| **年级升级** | 学年末一键升级 | ✅ 已实现(v1 P2-3 修复) | 已达标 |
|
||||
| **组织树导航** | 学校→年级→班级三级树 | ✅ 已实现(v1 P2-2 修复) | 已达标 |
|
||||
| **邀请码加入** | 6 位码 + 有效期 + 次数 | ✅ 已实现 | 已达标 |
|
||||
| **数据权限隔离** | data-access 层结合用户权限过滤 | ✅ 已实现(v1 P1-6 修复) | 已达标 |
|
||||
|
||||
### 3.2 多角色体验差距
|
||||
|
||||
| 角色 | 优秀实践 | 当前状态 |
|
||||
|------|---------|---------|
|
||||
| **admin** | 统一管理面板,学校/年级/班级三级联动,支持批量配置 | 分散在 4 个独立页面,无联动 |
|
||||
| **teacher** | 我的班级 + 可加入班级 + 邀请码管理一站式 | 有基本功能,但无权限校验、无 i18n |
|
||||
| **parent** | 查看孩子所在班级信息、任课教师、班级通知 | 无专属页面(依赖 dashboard 间接展示) |
|
||||
| **student** | 查看我的班级、同学名单、课表 | 有基本功能,但无权限校验、无 i18n |
|
||||
| **admin** | 统一管理面板,i18n 完整,错误边界完整 | school 模块 ✅,classes 模块 ❌(无 i18n、无 Error Boundary) |
|
||||
| **teacher** | 我的班级 + 邀请码 + 课表 + 学生,i18n 完整 | 有基本功能,但 ❌ 无 i18n、❌ 无 Error Boundary |
|
||||
| **grade_head** | 年级班级管理 + 学情洞察,i18n 完整 | 有基本功能,但 ❌ 组件无 i18n |
|
||||
| **parent** | 查看孩子所在班级信息 | 无专属页面(依赖 dashboard 间接展示) |
|
||||
| **student** | 查看我的班级、同学名单、课表 | 有基本功能,但 ❌ 无 i18n |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急 — 安全与架构正确性)
|
||||
### P0(紧急 — i18n 与错误处理)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | `grade-management` 模块完全未被使用 | **决策**:要么将 `admin/school/grades` 页面迁移到使用 `grade-management` 模块的 Widget + Service 模式,要么删除该死模块。**推荐迁移**,因为该模块实现了用户要求的全部原则(解耦/组合/i18n/复用/边界/可测试/可扩展) |
|
||||
| P0-2 | 年级 CRUD 逻辑重复 | 统一到 `grade-management` 模块,`school` 模块仅保留学校/学年/部门 CRUD,删除 school 模块中的 grade CRUD |
|
||||
| P0-3 | `classes/actions.ts` 974 行接近上限 | 按职责拆分为 `actions-teacher.ts` / `actions-admin.ts` / `actions-grade.ts` / `actions-invitations.ts` / `actions-schedule.ts` |
|
||||
| P0-4 | `teacher/classes/*` 4 页面无权限校验 | 每个页面添加 `requirePermission(Permissions.CLASS_READ)` 或对应权限点 |
|
||||
| P0-5 | 全部 13 页面无 i18n | 提取翻译键,使用 `getTranslations`(服务端组件)或 `useTranslations`(客户端组件)。补充 `school.json` 翻译文件 |
|
||||
| P0-A | classes 模块 13/14 个组件未接入 i18n | 为所有 classes 组件接入 `useTranslations("classes")`,提取全部硬编码文本到翻译键 |
|
||||
| P0-B | classes.json i18n 文件内容不足 | 扩充 `classes.json`,覆盖班级 CRUD、列表、筛选器、详情页、学生管理、课表、详情子组件等全部场景 |
|
||||
|
||||
### P1(重要 — 代码质量与可维护性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | `classes/actions.ts` 角色硬编码 | 将 `ctx.roles.includes("admin")` 改为 `ctx.hasPermission(Permissions.xxx)` 或 `ctx.roles` 中的权限点判断 |
|
||||
| P1-2 | `school` 模块无 i18n 文件 | 新建 `src/shared/i18n/messages/{zh-CN,en}/school.json` |
|
||||
| P1-3 | `school/components/*` 缺少 Error Boundary/Skeleton | 参照 `grade-management` 模块的 `grade-error-boundary.tsx` / `grade-skeleton.tsx` 模式补充 |
|
||||
| P1-4 | `classes/types.ts` 跨领域类型污染 | 将 `ClassHomeworkInsights` 等类型迁移至 homework 模块,classes 模块通过 import type 引用 |
|
||||
| P1-5 | `school/components/*` 未使用组合模式 | 将 `SchoolsClient` 拆分为 `SchoolListTable` + `SchoolFormDialog` + `SchoolDeleteDialog` + `SchoolListToolbar` |
|
||||
| P1-6 | data-access 层未结合权限过滤 | `getTeacherClasses(userId)` 接收 userId 参数,在查询中过滤 |
|
||||
| P1-A | `grades-view.tsx` 860 行 | 拆分为 `GradeListSection` + `GradeOverviewSection` + `GradeFormDialog` + `GradeDeleteDialog` + `GradePromoteDialog` + `use-grade-data` hook |
|
||||
| P1-B | `classes/data-access.ts` 833 行 | 将邀请码相关函数迁移至 `data-access-invitations.ts`,将教师班级管理迁移至 `data-access-teacher.ts` |
|
||||
| P1-C | classes 模块缺少 Error Boundary/Skeleton | 新增 `class-error-boundary.tsx` + `class-skeleton.tsx`,为所有 classes 页面包裹 |
|
||||
| P1-D | classes 组件未使用组合模式 | 将 `admin-classes-view` / `grade-classes-view` / `my-classes-grid` 拆分为可复用的 `ClassListTable` + `ClassFormDialog` + `ClassDeleteDialog` + `ClassListToolbar` |
|
||||
| P1-E | classes 模块缺少 hooks | 抽取 `use-class-data` hook(对话框状态管理)+ `use-class-filters` hook(筛选逻辑) |
|
||||
|
||||
### P2(优化 — 体验与扩展性)
|
||||
### P2(优化 — 架构模式)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 逻辑与 UI 耦合,难以单测 | 参照 `grade-management` 模块抽取 `use-school-data` / `use-class-data` 等 hooks |
|
||||
| P2-2 | 缺少年级→班级树形导航 | 新增 `OrgTreeNav` 组件,学校→年级→班级三级树 |
|
||||
| P2-3 | 缺少年级升级功能 | 新增 `promoteGradeAction`,学年末批量升级 |
|
||||
| P2-4 | 缺少批量操作 | 批量导入学生、批量分配教师 |
|
||||
| P2-5 | `school` 模块审计日志不一致 | 为 department/academicYear/grade 的 CRUD 补充 `logAudit` |
|
||||
|
||||
### 重构方案设计要点(参照用户强制原则)
|
||||
|
||||
1. **完全解耦**:以 `grade-management` 模块的 `GradeService` 接口 + `GradeServiceProvider` Context 注入为范本,为 school 和 classes 模块建立对应的 `SchoolService` / `ClassService` 接口
|
||||
2. **组合优先**:参照 `GradeManagementWidget` 的组合方式(Toolbar + Table + FormDialog + DeleteDialog),所有模块的 Widget 通过组合子组件实现
|
||||
3. **国际化就绪**:翻译文件结构示例
|
||||
```json
|
||||
// school.json
|
||||
{
|
||||
"schools": { "title": "学校管理", "list": { "title": "学校列表", "empty": "暂无学校" }, "form": { ... } },
|
||||
"grades": { "title": "年级管理", ... },
|
||||
"departments": { "title": "部门管理", ... },
|
||||
"academicYear": { "title": "学年管理", ... }
|
||||
}
|
||||
```
|
||||
4. **最大化复用**:抽取 `OrgCrudWidget<T>` 泛型组件(列表+工具栏+表单+删除),school/grade/department 共用
|
||||
5. **错误与边界**:每个 Widget 用 Error Boundary 包裹,异步数据用 Suspense + 骨架屏
|
||||
6. **可测试性**:数据获取/筛选/校验逻辑全部抽取为 hooks
|
||||
7. **可扩展性**:参照 `GRADE_ROLE_CONFIG`,为 school/classes 建立角色配置驱动设计
|
||||
8. **企业级补充**:a11y(语义化标签 + ARIA)、性能(RSC 获取初始数据)、安全(data-access 层权限过滤)、监控(`GradeAnalyticsTracker` 模式扩展到 school/classes)
|
||||
| P2-A | 缺少 Service 接口抽象 | 定义 `SchoolService` / `ClassService` 接口,通过 Context DI 注入(中长期) |
|
||||
| P2-B | 缺少角色配置驱动 | 建立 `CLASS_ROLE_CONFIG`,通过配置决定不同角色渲染哪些 Widget(中长期) |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图存在以下遗漏和不一致,需同步更新:
|
||||
### 5.1 需更新的节点
|
||||
|
||||
### 5.1 需补充的节点
|
||||
|
||||
| 文档 | 需补充内容 |
|
||||
| 文档 | 需更新内容 |
|
||||
|------|-----------|
|
||||
| `004_architecture_impact_map.md` | 新增 `## 2.X grade-management(年级管理模块)` 章节,记录其 services/hooks/widgets/config 架构,并标注"⚠️ 该模块当前未被任何 app 页面使用" |
|
||||
| `004_architecture_impact_map.md` | 在 `## 2.8 school` 章节补充说明:school 模块包含 grade CRUD 但与 grade-management 模块职责重叠 |
|
||||
| `004_architecture_impact_map.md` | 在路由表中补充 `teacher/classes/*` 4 个页面缺少 `requirePermission` 的标注 |
|
||||
| `005_architecture_data.json` | `modules` 节点新增 `grade-management` 模块及其 exports/dependencies |
|
||||
| `005_architecture_data.json` | `dependencyMatrix` 新增 grade-management → classes(通过 data-access)、grade-management → school 的依赖关系 |
|
||||
| `005_architecture_data.json` | `routes` 节点补充 teacher/classes/* 的权限缺失标注 |
|
||||
| `004_architecture_impact_map.md` §2.7 classes | 更新组件 i18n 状态(❌ → ✅)、更新 Error Boundary 状态(❌ → ✅)、更新组合模式状态、更新文件行数 |
|
||||
| `004_architecture_impact_map.md` §2.8 school | 更新 `grades-view.tsx` 行数(860 → 拆分后)、更新组件拆分情况 |
|
||||
| `005_architecture_data.json` | 更新 classes 模块的 i18n/errorBoundary/composition 状态标记 |
|
||||
|
||||
### 5.2 需修改的节点
|
||||
### 5.2 实施后同步
|
||||
|
||||
| 文档 | 需修改内容 |
|
||||
|------|-----------|
|
||||
| `004_architecture_impact_map.md` §2.7 classes | 更新 `actions.ts` 行数(676 → 974),标注接近硬上限 |
|
||||
| `004_architecture_impact_map.md` §2.8 school | 更新 `data-access.ts` 行数(186 → 504),补充新增的跨模块查询函数(`getGradeNameById` / `getSubjectNameById` / `isGradeHead` / `isGradeManager` / `findGradeIdByHeadAndName`) |
|
||||
|
||||
### 5.3 i18n 翻译文件结构示例
|
||||
|
||||
```
|
||||
src/shared/i18n/messages/
|
||||
├─ zh-CN/
|
||||
│ ├─ school.json ← 新增(学校/学年/部门管理翻译键)
|
||||
│ ├─ grade.json ← 已存在(年级管理翻译键,grade-management 模块用)
|
||||
│ └─ classes.json ← 已存在(班级管理翻译键,需扩充)
|
||||
└─ en/
|
||||
├─ school.json ← 新增
|
||||
├─ grade.json ← 已存在
|
||||
└─ classes.json ← 已存在
|
||||
```
|
||||
本次审计实施后,需同步更新:
|
||||
- classes 模块的 i18n 接入状态
|
||||
- classes 模块的 Error Boundary/Skeleton 新增
|
||||
- grades-view.tsx 拆分后的文件清单
|
||||
- classes/data-access.ts 拆分后的文件清单
|
||||
- classes 组件拆分后的文件清单
|
||||
|
||||
---
|
||||
|
||||
## 附录:审计检查清单
|
||||
## 附录:审计检查清单(v2)
|
||||
|
||||
| 检查项 | school | grade-management | classes |
|
||||
|--------|:------:|:---------------:|:-------:|
|
||||
| 三层架构划分合理 | ✅ | ✅ | ⚠️ actions.ts 过大 |
|
||||
| 文件大小符合规范 | ✅ | ✅ | ❌ actions.ts 974 行 |
|
||||
| 无跨模块直接依赖 | ✅ | ✅ | ✅ |
|
||||
| Server Action 权限校验 | ✅ | ✅ | ⚠️ 角色硬编码 |
|
||||
| 前端无 role 硬编码 | ✅ | ✅ | ✅ |
|
||||
| i18n 适配 | ❌ | ✅(组件层) | ❌ |
|
||||
| 错误处理/边界 | ❌ | ✅ | ❌ |
|
||||
| 骨架屏/空状态 | ⚠️ 部分 | ✅ | ⚠️ 部分 |
|
||||
| 逻辑与 UI 分离 | ❌ | ✅ | ⚠️ 部分 |
|
||||
| 组合模式 | ❌ | ✅ | ⚠️ 部分 |
|
||||
| 配置驱动 | ❌ | ✅ | ❌ |
|
||||
| 被页面实际使用 | ✅ | ❌ **死模块** | ✅ |
|
||||
| 审计日志完整 | ⚠️ 不一致 | ✅ | ✅ |
|
||||
| 监控埋点接口 | ❌ | ✅(预留) | ❌ |
|
||||
| 检查项 | school | classes | 状态 |
|
||||
|--------|:------:|:-------:|------|
|
||||
| 三层架构划分合理 | ✅ | ✅ | v1 已修复 |
|
||||
| 文件大小符合规范 | ⚠️ grades-view 860 行 | ⚠️ data-access 833 行 | P1-A/P1-B |
|
||||
| 无跨模块直接依赖 | ✅ | ✅ | v1 已修复 |
|
||||
| Server Action 权限校验 | ✅ | ✅ | v1 已修复 |
|
||||
| 前端无 role 硬编码 | ✅ | ✅ | v1 已修复 |
|
||||
| i18n 适配 | ✅ | ❌ 13/14 组件未接入 | P0-A/P0-B |
|
||||
| 错误处理/边界 | ✅ | ❌ 无 Error Boundary | P1-C |
|
||||
| 骨架屏/空状态 | ✅ | ⚠️ 有空状态无骨架屏 | P1-C |
|
||||
| 逻辑与 UI 分离 | ✅ use-school-data | ❌ 无 hooks | P1-E |
|
||||
| 组合模式 | ✅ | ❌ 三套单体组件 | P1-D |
|
||||
| 配置驱动 | ❌ | ❌ | P2-B(中长期) |
|
||||
| 审计日志完整 | ✅ | ✅ | v1 已修复 |
|
||||
| 监控埋点接口 | ❌ | ❌ | P2(中长期) |
|
||||
|
||||
251
docs/architecture/audit/settings-profile-audit-report-v3.md
Normal file
251
docs/architecture/audit/settings-profile-audit-report-v3.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# 个人信息配置和设置模块审计报告 v3
|
||||
|
||||
> 审查日期:2026-06-22
|
||||
> 审查范围:`src/modules/settings/**`、`src/app/(dashboard)/settings/**`、`src/app/(dashboard)/admin/settings/**`、`src/app/(dashboard)/profile/**`
|
||||
> 上一版本:`settings-profile-audit-report-v2.md`(v2,12 项已全部完成)
|
||||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.23、`docs/architecture/005_architecture_data.json`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
| 层 | 路径 | 文件数 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| 路由层 - 通用设置 | `src/app/(dashboard)/settings/` | `page.tsx` + `error.tsx` + `loading.tsx` | 角色分发到 SettingsView,通过 SettingsServiceProvider 注入服务 |
|
||||
| 路由层 - 管理员系统设置 | `src/app/(dashboard)/admin/settings/` | `page.tsx` | 仅 admin 可访问,渲染 AdminSettingsView |
|
||||
| 路由层 - 安全设置 | `src/app/(dashboard)/settings/security/` | `page.tsx` + `error.tsx` + `loading.tsx` | 独立密码修改页 |
|
||||
| 路由层 - 个人资料 | `src/app/(dashboard)/profile/` | `page.tsx` + `error.tsx` + `loading.tsx` | 个人资料展示页(159 行) |
|
||||
| 模块层 - actions | 7 个文件 | 详见下表 | 全部使用 `requirePermission()` |
|
||||
| 模块层 - data-access | 3 个文件 | 详见下表 | 全部 `server-only` |
|
||||
| 模块层 - types | `types.ts`(75 行) | 1 | AiProvider 类型 + SettingsService 接口 |
|
||||
| 模块层 - 组件 | 12 个组件 | 详见下表 | |
|
||||
| 模块层 - lib | 3 个纯函数文件 + 3 个测试 | | |
|
||||
| 模块层 - config | `role-settings-config.tsx`(84 行) | 1 | 配置驱动角色路由 |
|
||||
| i18n | `zh-CN/settings.json` + `en/settings.json` | 2 | 完整翻译 |
|
||||
|
||||
### 1.2 v1/v2 完成情况回顾
|
||||
|
||||
v1 报告 13 项 + v2 报告 12 项改进建议已全部完成:
|
||||
|
||||
- ✅ i18n 命名空间创建(settings.json 中英文)
|
||||
- ✅ SettingsService 接口 + Context 注入(消除跨模块 action 直调)
|
||||
- ✅ AdminSettingsView 接入真实数据层(system_settings 表)
|
||||
- ✅ 配置驱动角色路由(ROLE_SETTINGS_CONFIG)
|
||||
- ✅ 分区 Error Boundary + Suspense 骨架屏
|
||||
- ✅ Profile 页面拆分(ProfileStudentOverview / ProfileTeacherOverview)
|
||||
- ✅ 头像上传 + 旧文件清理
|
||||
- ✅ 2FA 完整 TOTP 实现(非占位)
|
||||
- ✅ 通知测试按钮接入真实 dispatcher
|
||||
- ✅ 会话远程登出
|
||||
- ✅ AdminSettingsView dirty 检测
|
||||
- ✅ 通知偏好表单 dirty 检测
|
||||
- ✅ 单元测试(totp / student-overview-data / security-utils)
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
`004_architecture_impact_map.md` §2.23 和 `005_architecture_data.json` 的 settings 节点记录完整,包含所有 actions / data-access / components / config / lib / types 的导出、依赖关系和已知问题状态。架构图与实际代码基本一致。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 ProfileStudentOverview / ProfileTeacherOverview 跨模块 data-access 直调(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [profile-student-overview.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-student-overview.tsx) L8-9 | `import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"` / `import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"` | "模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)" |
|
||||
| [profile-teacher-overview.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-teacher-overview.tsx) L6 | `import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"` | 同上 |
|
||||
| [profile-student-overview.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-student-overview.tsx) L4-7 | `import { StudentGradesCard } ... from "@/modules/dashboard/components/student-dashboard/*"` | 组件层跨模块直接 import dashboard 组件,耦合度高 |
|
||||
|
||||
**原因**:Profile 概览组件作为 Server Component 直接编排 classes/homework/dashboard 模块的数据获取和组件渲染,未通过接口抽象。
|
||||
|
||||
**后果**:classes/homework/dashboard 模块的 data-access 签名变更会直接破坏 settings 组件;settings 模块无法独立测试(mock classes/homework data-access 困难);无法在不修改 settings 组件的前提下替换数据源。
|
||||
|
||||
### 2.2 profile/page.tsx 角色硬编码(P0)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) L37 | `const isStudent = roles.includes("student")` | "前端权限判断统一使用 usePermission().hasPermission(),严禁出现 role === 'xxx' 硬编码" |
|
||||
| 同文件 L38 | `const isTeacher = roles.includes("teacher")` | 同上 |
|
||||
|
||||
**原因**:Profile 页面通过 `roles.includes()` 判断角色来决定渲染学生/教师概览区块,未使用权限点或配置驱动。
|
||||
|
||||
**后果**:新增角色(如 grade_head)需修改页面代码;角色与概览区块的映射关系不可配置;违反项目硬编码禁令。
|
||||
|
||||
### 2.3 SecurityCenterCard 超出组件行数上限(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [security-center-card.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/security-center-card.tsx) | 645 行 | "React 组件:建议 ≤ 500 行(复杂表单/大型表格可放宽至 800 行)" |
|
||||
|
||||
**原因**:单个组件文件混合了 2FA 启用流程、2FA 关闭流程、备份码重新生成流程、最近登录历史列表、远程登出 5 个独立交互区块,以及 3 个 Dialog 的状态管理和 JSX。
|
||||
|
||||
**后果**:可读性下降,维护困难;难以独立测试各交互区块;修改一个流程容易影响其他流程。
|
||||
|
||||
**建议**:拆分为 `SecurityTwoFactorSection`(2FA 启用/关闭/备份码)、`SecurityRecentLoginsSection`(登录历史 + 远程登出),主组件仅负责数据加载和组合。
|
||||
|
||||
### 2.4 ai-provider-settings-card.tsx 超出组件行数上限(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [ai-provider-settings-card.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/ai-provider-settings-card.tsx) | 529 行 | 同上 |
|
||||
|
||||
**原因**:单个组件文件混合了 Provider 列表选择、表单编辑、测试、保存、删除 5 个交互流程,以及 visibility/isDefault 表单字段。
|
||||
|
||||
**后果**:可读性下降,维护困难。
|
||||
|
||||
**建议**:将 Provider 选择器和 keyStatus 显示拆分为 `AiProviderSelector`,表单主体保留在主组件,删除确认 Dialog 拆为 `AiProviderDeleteDialog`。
|
||||
|
||||
### 2.5 AdminSettingsView 未拆分为子组件(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [admin-settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/admin-settings-view.tsx) | 444 行,4 个 Card 内联在单文件中 | v2 报告建议拆分为 `SchoolInfoCard` / `SecurityPolicyCard` / `FileUploadCard` / `NotificationConfigCard` |
|
||||
|
||||
**原因**:v2 报告已建议拆分但未实施。
|
||||
|
||||
**后果**:4 个 Card 的表单字段和状态更新逻辑混合在主组件中,难以独立测试和复用。
|
||||
|
||||
### 2.6 settings/page.tsx metadata 硬编码英文(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [settings/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/settings/page.tsx) L19-21 | `metadata = { title: "Settings" }` 硬编码英文 | "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键" |
|
||||
|
||||
**原因**:使用静态 `metadata` 导出而非 `generateMetadata` + `getTranslations`。
|
||||
|
||||
**后果**:中文环境下浏览器标签页显示英文 "Settings"。
|
||||
|
||||
### 2.7 i18n 命名空间不一致(P1)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [avatar-upload.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/avatar-upload.tsx) L40 | `useTranslations("settings.profile.avatar")` | 命名空间为 `settings.profile.avatar` |
|
||||
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) L22 | `getTranslations("settings.profilePage")` | 命名空间为 `settings.profilePage` |
|
||||
| [profile-settings-form.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/profile-settings-form.tsx) L39 | `useTranslations("settings.profile")` | 命名空间为 `settings.profile` |
|
||||
|
||||
**原因**:三处使用了三种不同的 i18n 命名空间根(`settings.profile` / `settings.profilePage` / `settings.profile.avatar`),v2 报告已指出但未统一。
|
||||
|
||||
**后果**:i18n 命名空间结构混乱,维护时易混淆;翻译键分散在多个命名空间下。
|
||||
|
||||
**建议**:统一为 `settings.profile.*`(AvatarUpload 改为 `settings.profile.avatar.*`,profile/page.tsx 改为 `settings.profile.*`),将 `profilePage` 命名空间下的键合并到 `profile` 下。
|
||||
|
||||
### 2.8 缺少 toSettingItem 单元测试(P2)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| `src/modules/settings/actions-system-settings.ts` L58-75 | `toSettingItem` 纯函数无单元测试 | v2 报告建议添加 |
|
||||
|
||||
**原因**:v2 建议未实施。
|
||||
|
||||
**后果**:值类型转换逻辑(string/number/boolean/json)无回归保障。
|
||||
|
||||
### 2.9 profile 页 AvatarUpload 未包裹 Error Boundary(P2)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [profile/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/profile/page.tsx) L53-57 | `<AvatarUpload>` 直接渲染,无 Error Boundary 包裹 | "每个独立的数据区块必须用 React Error Boundary 包裹" |
|
||||
|
||||
**原因**:仅学生/教师概览区块包裹了 Error Boundary,AvatarUpload 区块遗漏。
|
||||
|
||||
**后果**:头像上传失败(网络异常/文件服务不可用)会导致整页崩溃。
|
||||
|
||||
### 2.10 settings-view.tsx 直接 import next-auth/react signOut(P2)
|
||||
|
||||
| 位置 | 问题 | 违反规则 |
|
||||
|------|------|----------|
|
||||
| [settings-view.tsx](file:///e:/Desktop/CICD/src/modules/settings/components/settings-view.tsx) L8 | `import { signOut } from "next-auth/react"` | 模块内部组件直接耦合认证实现 |
|
||||
|
||||
**原因**:登出按钮直接调用 next-auth 的客户端 signOut。
|
||||
|
||||
**后果**:settings 模块耦合 next-auth 实现;如未来更换认证方案需修改 settings 组件。
|
||||
|
||||
**建议**:将 `signOut` 调用封装为 settings 模块自身的 action 或通过 props 注入。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与优秀 K12 产品的差距
|
||||
|
||||
| 维度 | 优秀实践(Google Classroom / PowerSchool / Veracross) | 当前状态 | 差距影响 |
|
||||
|------|--------------------------------------------------------|----------|----------|
|
||||
| **设置信息架构** | 统一入口,按角色动态显示分组,支持搜索 | ✅ 已统一入口,配置驱动角色路由 | 已达标 |
|
||||
| **个人资料** | 头像上传 + 字段级权限可见性 | ✅ 头像上传已实现;字段级权限可见性未实现 | 学生可能看到不该看的字段(如自己的手机号由家长管理) |
|
||||
| **安全中心** | 2FA、会话列表、登录历史、密码泄露检测 | ✅ 2FA TOTP + 会话登出 + 登录历史 | 已达标,密码泄露检测(HaveIBeenPwned)未集成 |
|
||||
| **通知偏好** | 按事件类型细分,支持渠道矩阵 + 免打扰 + 测试 | ✅ 全部已实现 | 已达标 |
|
||||
| **主题/语言** | 主题切换 + 语言切换同页 | ✅ 已集成 | 已达标 |
|
||||
| **AI 配置** | 多 Provider + 测试 + 用量统计 | ✅ 多 Provider + 测试,无用量统计 | 教育机构无法监控 AI 成本(中长期计划) |
|
||||
| **空状态/骨架屏** | 每个数据区块独立骨架屏 + 空状态 | ✅ 已实现分区 Suspense + 骨架屏 | 已达标 |
|
||||
| **设置搜索** | 设置项较多时支持快速搜索 | ❌ 未实现 | 设置项目前 4 个标签页,数量尚可,中长期可考虑 |
|
||||
|
||||
### 3.2 多角色使用习惯
|
||||
|
||||
| 角色 | 优秀实践 | 当前状态 |
|
||||
|------|----------|----------|
|
||||
| **admin** | 系统设置与个人设置在同一入口的不同分组 | ✅ `/settings` 个人设置 + `/admin/settings` 系统设置分离 |
|
||||
| **teacher** | 设置页可快速跳转常用教学功能 | ✅ 有 QuickLinksCard |
|
||||
| **parent** | 设置页可切换查看不同孩子的通知偏好 | ❌ 仅一套偏好,无法按孩子细分(中长期计划) |
|
||||
| **student** | 设置页简洁,无系统配置 | ✅ 简洁 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响架构合规)
|
||||
|
||||
1. **消除 Profile 概览组件跨模块 data-access 直调**:将 `ProfileStudentOverview` / `ProfileTeacherOverview` 改为通过 props 接收数据,由页面层(app 层)编排 classes/homework data-access 并注入。同时将 dashboard 组件引用改为通过 children/props 传入或抽取为 shared 组件。
|
||||
2. **消除 profile/page.tsx 角色硬编码**:将 `roles.includes("student")` / `roles.includes("teacher")` 改为配置驱动或权限点判断。
|
||||
|
||||
### P1(重要,影响可维护性)
|
||||
|
||||
3. **拆分 SecurityCenterCard**:645 行 → 拆分为 `SecurityTwoFactorSection` + `SecurityRecentLoginsSection`,主组件负责数据加载和组合。
|
||||
4. **拆分 ai-provider-settings-card.tsx**:529 行 → 拆分 Provider 选择器和删除确认 Dialog。
|
||||
5. **拆分 AdminSettingsView**:4 个 Card 拆分为独立子组件。
|
||||
6. **settings/page.tsx metadata i18n 化**:改为 `generateMetadata` + `getTranslations`。
|
||||
7. **统一 i18n 命名空间**:将 `settings.profilePage.*` 合并到 `settings.profile.*`,AvatarUpload 保持 `settings.profile.avatar.*`。
|
||||
|
||||
### P2(优化,提升质量)
|
||||
|
||||
8. **添加 toSettingItem 单元测试**。
|
||||
9. **profile 页 AvatarUpload 包裹 Error Boundary**。
|
||||
10. **封装 signOut 调用**:通过 props 或 action 注入,解耦 next-auth。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图需补充/修改以下节点:
|
||||
|
||||
### 5.1 `004_architecture_impact_map.md` §2.23
|
||||
|
||||
- **修改"已知问题"**:新增 v3 发现的 3 项 P0/P1 问题(Profile 概览跨模块 data-access 直调 / profile 角色硬编码 / SecurityCenterCard 超行数上限)
|
||||
- **更新"文件清单"**:新增拆分后的子组件(SecurityTwoFactorSection / SecurityRecentLoginsSection / AiProviderSelector / SchoolInfoCard / SecurityPolicyCard / FileUploadCard / NotificationConfigCard)
|
||||
- **更新行数**:SecurityCenterCard 拆分后行数变化
|
||||
|
||||
### 5.2 `005_architecture_data.json` settings 节点
|
||||
|
||||
- **`modules.settings.knownIssues`**:新增 v3 问题状态
|
||||
- **`modules.settings.exports.components`**:新增拆分后的子组件
|
||||
- **`dependencyMatrix`**:settings → classes/homework/dashboard 的依赖类型标注为"组件层直调(待修复)"
|
||||
|
||||
---
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
v3 完成后应满足:
|
||||
|
||||
1. `npm run lint` 零错误(warnings 可接受)
|
||||
2. `npx tsc --noEmit` 零错误
|
||||
3. Profile 概览组件不直接 import classes/homework/dashboard 模块
|
||||
4. profile/page.tsx 无 `roles.includes()` 硬编码
|
||||
5. SecurityCenterCard 拆分后主文件 ≤ 300 行
|
||||
6. ai-provider-settings-card.tsx 拆分后 ≤ 400 行
|
||||
7. AdminSettingsView 拆分为 4 个子 Card 组件
|
||||
8. settings/page.tsx 使用 generateMetadata
|
||||
9. i18n 命名空间统一为 `settings.profile.*`
|
||||
10. toSettingItem 有单元测试
|
||||
11. AvatarUpload 被 Error Boundary 包裹
|
||||
12. 架构图 004/005 已同步更新
|
||||
409
docs/architecture/audit/textbooks-audit-report-v3.md
Normal file
409
docs/architecture/audit/textbooks-audit-report-v3.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# 教材(Textbooks)模块审计报告 v3
|
||||
|
||||
> 审计日期:2026-06-24
|
||||
> 审计范围:`src/modules/textbooks/**`、`src/app/(dashboard)/teacher/textbooks/**`、`src/app/(dashboard)/student/learning/textbooks/**`
|
||||
> 对比基准:[textbooks-audit-report-v2.md](./textbooks-audit-report-v2.md)(v2)
|
||||
> 参照规则:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`.trae/rules/project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 模块定位
|
||||
|
||||
教材模块是 K12 系统的"标杆模块"(架构图 004 第 2.5 节明确标注),承担**教材与知识体系管理**职责:
|
||||
- 教材/章节树形结构 CRUD
|
||||
- 知识点 CRUD + Markdown 内容编辑
|
||||
- 知识图谱可视化(React Flow + dagre 布局)
|
||||
- 知识点前置依赖管理(含循环检测)
|
||||
- 学生/班级掌握度聚合查询
|
||||
|
||||
### 1.2 文件分布与规模
|
||||
|
||||
| 层 | 文件数 | 总行数 | 最大文件 | 备注 |
|
||||
|----|--------|--------|----------|------|
|
||||
| 模块核心(modules/textbooks) | 26 | ~3,800 | `data-access.ts` 662 行 | 含 5 个 hooks、16 个组件 |
|
||||
| App 层 - 教师 | 6 | ~350 | `[id]/page.tsx` 66 行 | 含 loading/error |
|
||||
| App 层 - 学生 | 6 | ~240 | `page.tsx` 86 行 | 含 loading/error |
|
||||
| 测试 | 2 | ~200 | `utils.test.ts` | 纯函数单测 |
|
||||
|
||||
### 1.3 数据流概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ App 层(RSC) │
|
||||
│ teacher/textbooks/page.tsx ──┐ │
|
||||
│ student/learning/textbooks/page.tsx ──┤ │
|
||||
└────────────────────────────────┼─────────────────────────────────┘
|
||||
│ 直接调用 data-access(读)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ modules/textbooks │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ actions.ts │──▶│ data-access.ts│──▶│ data-access-graph.ts│ │
|
||||
│ │ (14 Actions) │ │ (662 行) │ │ (207 行, 图谱只读) │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ ▼ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ │ utils.ts │ │ graph-layout │ │
|
||||
│ │ │ (225 行纯函数)│ │ (121 行纯函数)│ │
|
||||
│ │ └──────────────┘ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ components/(16 个组件)+ hooks/(5 个 Hook) │ │
|
||||
│ │ textbook-reader / knowledge-graph / chapter-sidebar-list │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ 跨模块依赖(通过 data-access,合规)
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ users.data-access(getCurrentStudentUser) │
|
||||
│ classes.data-access-students(getClassStudents) │
|
||||
│ school.data-access(getGradeNameById) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.4 架构图记录完整性评估
|
||||
|
||||
| 架构图节点 | 记录状态 | 差异 |
|
||||
|------------|----------|------|
|
||||
| 004 § 2.5 textbooks | ✅ 完整 | 导出函数、依赖、已知问题、文件清单均已记录 |
|
||||
| 005 textbooks 节点 | ✅ 完整 | actions/dataAccess/hooks/types/components/files 均已记录 |
|
||||
| 已知问题记录 | ⚠️ 部分过期 | 005 `knownIssues` 仍写"i18n 覆盖率约 98%",实际审计发现残留硬编码英文异常文案、a11y 缺失等问题未记录 |
|
||||
|
||||
**结论**:架构图对教材模块的覆盖度约 90%,主要差距在 `knownIssues` 字段未同步最新审计发现。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 安全与权限维度(P0)
|
||||
|
||||
#### 问题 T-AUDIT-01 | 学生端页面缺少 `requirePermission` 显式校验
|
||||
|
||||
- **位置**:[student/learning/textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/learning/textbooks/page.tsx)、[student/learning/textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx)
|
||||
- **现象**:两个学生端页面均未调用 `requirePermission(Permissions.TEXTBOOK_READ)`,仅靠 `getCurrentStudentUser()` 隐式校验角色。对比教师端 [teacher/textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx) 第 18 行显式调用 `requirePermission`。
|
||||
- **违反规则**:`project_rules.md` → "Server Action 规范" → "每个 Action 必须调用 `requirePermission()` 进行权限校验";以及"安全规范" → "所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验"
|
||||
- **原因**:学生端页面是 RSC 直接调用 data-access(非 Server Action),开发者认为 `getCurrentStudentUser` 已隐含角色校验,但权限点 `TEXTBOOK_READ` 未被显式校验,无法通过权限矩阵灵活调整
|
||||
- **后果**:若未来权限矩阵调整(如临时禁止某学生查看教材),学生端无法响应;权限审计日志缺失"教材阅读"事件
|
||||
|
||||
#### 问题 T-AUDIT-02 | `getKnowledgeGraphDataAction` 缺少数据范围过滤
|
||||
|
||||
- **位置**:[actions.ts](file:///e:/Desktop/CICD/src/modules/textbooks/actions.ts) 第 373-413 行
|
||||
- **现象**:`getKnowledgeGraphDataAction` 仅校验 `TEXTBOOK_READ` 权限,未按学生年级做 scope 过滤(对比 `getTextbooksWithScope` 有 scope.grade 过滤)。学生可传任意 `textbookId` 查看跨年级教材的图谱数据
|
||||
- **违反规则**:`project_rules.md` → "安全规范" → "所有敏感数据查询必须在 data-access 层结合当前用户权限过滤"
|
||||
- **原因**:图谱查询是 Task 7 新增功能,开发时复用了教师端逻辑,未区分学生数据范围
|
||||
- **后果**:学生可越权查看非本年级教材的知识图谱与掌握度数据
|
||||
|
||||
#### 问题 T-AUDIT-03 | `class-mastery` 视图缺少教师-班级归属校验
|
||||
|
||||
- **位置**:[actions.ts](file:///e:/Desktop/CICD/src/modules/textbooks/actions.ts) 第 393-403 行
|
||||
- **现象**:`getKnowledgeGraphDataAction` 的 `class-mastery` 分支调用 `getClassStudents({ status: "active" })` 获取全量班级学生 ID,未校验当前教师是否拥有这些班级
|
||||
- **违反规则**:`project_rules.md` → "安全规范" → "Server Action 二次校验"
|
||||
- **原因**:直接调用 classes 模块的 `getClassStudents` 未传入教师 scope
|
||||
- **后果**:任何持有 `TEXTBOOK_READ` 权限的教师可查看全校所有班级的掌握度数据
|
||||
|
||||
### 2.2 数据完整性维度(P0)
|
||||
|
||||
#### 问题 T-AUDIT-04 | `deleteChapter` 未清理孤儿前置依赖记录
|
||||
|
||||
- **位置**:[data-access.ts](file:///e:/Desktop/CICD/src/modules/textbooks/data-access.ts) 第 259-298 行
|
||||
- **现象**:递归删除章节及其知识点时,未删除 `knowledgePointPrerequisites` 表中引用被删知识点的边(包括作为 `prerequisiteKpId` 的记录)
|
||||
- **违反规则**:数据完整性最佳实践
|
||||
- **原因**:删除逻辑只关注了 `knowledgePoints` 表本身,遗漏了外键关联表
|
||||
- **后果**:图谱渲染时出现指向已删除知识点的孤儿边,循环检测算法 `hasCycleAfterAddingEdge` 可能因引用不存在节点而异常
|
||||
|
||||
### 2.3 类型安全维度(P1)
|
||||
|
||||
#### 问题 T-AUDIT-05 | `as` 断言违反规范
|
||||
|
||||
- **位置**:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/textbooks/data-access.ts) 第 661 行 `as [string, string]`
|
||||
- [knowledge-point-dialogs.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/knowledge-point-dialogs.tsx) 第 84 行 `as (formData: FormData) => void`
|
||||
- [graph-kp-node.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/graph-kp-node.tsx) 第 35、37 行 `as unknown as GraphLayoutNodeData`(双重断言,缺注释)
|
||||
- **违反规则**:`project_rules.md` → "TypeScript 规则" → "禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)"
|
||||
- **原因**:React Flow 的 `NodeProps` 类型与自定义数据结构不匹配,开发者用 `as unknown as` 绕过类型检查
|
||||
- **后果**:类型安全被削弱,运行时可能因数据形状不符而崩溃
|
||||
|
||||
#### 问题 T-AUDIT-06 | 类型与 DB Schema 不一致
|
||||
|
||||
- **位置**:[types.ts](file:///e:/Desktop/CICD/src/modules/textbooks/types.ts)
|
||||
- **现象**:
|
||||
- 第 42 行 `KnowledgePoint.chapterId?: string`(可选),但 DB schema 中该字段非空
|
||||
- 第 13 行 `Textbook.grade: string | null`(可空),但 `CreateTextbookSchema.grade` 要求 `min(1)` 非空
|
||||
- **违反规则**:`project_rules.md` → "TypeScript 规则" → "禁止 `any`"
|
||||
- **原因**:类型手动定义而非从 Drizzle schema 推断(types.ts 第 1-3 行注释承认)
|
||||
- **后果**:下游需大量 `?? undefined` 兜底(data-access.ts 第 320、347 行),类型契约不可信
|
||||
|
||||
### 2.4 错误处理维度(P0/P1)
|
||||
|
||||
#### 问题 T-AUDIT-07 | 前置依赖操作无 try/catch,loading 状态卡死
|
||||
|
||||
- **位置**:[knowledge-graph.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/knowledge-graph.tsx) 第 202-219 行(`handleAddPrerequisite`)、第 222-235 行(`handleRemovePrerequisite`)
|
||||
- **现象**:两个函数均无 try/catch,若 action 抛异常,`setIsSavingPrereq(false)` 不会执行
|
||||
- **违反规则**:`project_rules.md` → "错误处理" 隐含要求
|
||||
- **原因**:开发者假设 Server Action 不会抛异常(实际网络异常、服务端 500 均会抛)
|
||||
- **后果**:用户点击"添加前置依赖"失败后,按钮永久 loading,需刷新页面
|
||||
|
||||
#### 问题 T-AUDIT-08 | `textbook-reader.tsx` `onCreateKnowledgePoint` 无 try/catch
|
||||
|
||||
- **位置**:[textbook-reader.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-reader.tsx) 第 177-181 行
|
||||
- **现象**:`onCreateKnowledgePoint` 回调中调用 `handleCreateKnowledgePoint`,无 try/catch,异常时 `setIsCreating(false)` 不执行
|
||||
- **后果**:创建知识点失败时,UI 永久显示创建中状态
|
||||
|
||||
#### 问题 T-AUDIT-09 | `section-error-boundary.tsx` 缺 `componentDidCatch`
|
||||
|
||||
- **位置**:[section-error-boundary.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/section-error-boundary.tsx)
|
||||
- **现象**:仅实现 `getDerivedStateFromError`,未实现 `componentDidCatch`,错误被吞没未上报
|
||||
- **违反规则**:React Error Boundary 最佳实践
|
||||
- **后果**:生产环境无法追踪章节内容渲染错误
|
||||
|
||||
#### 问题 T-AUDIT-10 | 4 个 error.tsx 完全重复且未记录日志
|
||||
|
||||
- **位置**:4 个 error.tsx 文件(教师/学生 × 列表/详情)
|
||||
- **现象**:内容完全相同(仅函数名不同),`error` 参数接收但未 `console.error` 记录、未展示 `error.digest`、未区分错误类型
|
||||
- **违反规则**:DRY 原则、Next.js error.tsx 最佳实践
|
||||
- **后果**:错误无法追踪;4 份重复代码维护成本高
|
||||
|
||||
### 2.5 国际化维度(P1)
|
||||
|
||||
#### 问题 T-AUDIT-11 | data-access 层硬编码英文异常文案
|
||||
|
||||
- **位置**:[data-access.ts](file:///e:/Desktop/CICD/src/modules/textbooks/data-access.ts) 第 188、244、383 行
|
||||
- **现象**:`throw new Error("Textbook not found")` / `"Chapter not found"` 等英文硬编码
|
||||
- **违反规则**:`project_rules.md` → "所有用户可见文本必须适配 i18n"
|
||||
- **原因**:data-access 层无法直接调用 `getTranslations`(需 async context)
|
||||
- **后果**:错误冒泡到 `handleActionError` 后,非英文用户看到英文错误信息
|
||||
|
||||
#### 问题 T-AUDIT-12 | `use-graph-data.ts` 错误状态值类型不一致
|
||||
|
||||
- **位置**:[use-graph-data.ts](file:///e:/Desktop/CICD/src/modules/textbooks/hooks/use-graph-data.ts) 第 60、66 行
|
||||
- **现象**:`error` 状态有时是服务端已翻译文案(`result.message`),有时是原始 i18n key 字符串(`"graph.error.loadFailed"`),有时是 JS Error.message
|
||||
- **后果**:下游组件无法判断该直接显示还是再 `t()` 翻译
|
||||
|
||||
### 2.6 可访问性维度(P1)
|
||||
|
||||
#### 问题 T-AUDIT-13 | 章节项可点击 div 无键盘支持
|
||||
|
||||
- **位置**:[chapter-sidebar-list.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/chapter-sidebar-list.tsx) 第 100-110 行
|
||||
- **现象**:章节项点击区是 `<div onClick>`,缺少 `role="button"`、`tabIndex={0}`、`onKeyDown`
|
||||
- **违反规则**:WCAG 2.1 AA → 键盘可达性
|
||||
- **后果**:键盘用户无法选中章节
|
||||
|
||||
#### 问题 T-AUDIT-14 | 图标按钮缺 `aria-label`
|
||||
|
||||
- **位置**:[graph-node-detail-panel.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/graph-node-detail-panel.tsx) 第 45、139-146 行
|
||||
- **现象**:关闭按钮(X 图标)、删除前置按钮(Trash2 图标)无 `aria-label`
|
||||
- **后果**:屏幕阅读器用户无法识别按钮用途
|
||||
|
||||
#### 问题 T-AUDIT-15 | loading.tsx 缺少 ARIA 标记
|
||||
|
||||
- **位置**:4 个 loading.tsx 文件
|
||||
- **现象**:根容器缺少 `role="status"` 和 `aria-busy="true"`
|
||||
- **后果**:屏幕阅读器无法感知加载状态
|
||||
|
||||
#### 问题 T-AUDIT-16 | error.tsx 缺少 `role="alert"`
|
||||
|
||||
- **位置**:4 个 error.tsx 文件
|
||||
- **现象**:错误容器缺少 `role="alert"`,屏幕阅读器不会自动播报错误
|
||||
|
||||
### 2.7 跨模块耦合维度(P1)
|
||||
|
||||
#### 问题 T-AUDIT-17 | 跨模块 URL 硬编码
|
||||
|
||||
- **位置**:
|
||||
- [graph-node-detail-panel.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/graph-node-detail-panel.tsx) 第 101 行 `<Link href={`/teacher/questions?kp=${kp.id}`}>`
|
||||
- [textbook-reader.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-reader.tsx) 第 378 行 `<Link href={`/teacher/lesson-plans/new?...`}>`
|
||||
- [textbook-card.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-card.tsx) 第 47 行 `hrefBase` 默认值 `/teacher/textbooks`
|
||||
- [textbook-settings-dialog.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-settings-dialog.tsx) 第 80 行 `router.push("/teacher/textbooks")`
|
||||
- **现象**:组件内硬编码角色前缀 URL(`/teacher/...`),无法复用于学生/家长端
|
||||
- **违反规则**:`project_rules.md` → "前端组件禁止使用 `role === "xxx"` 硬编码"(URL 硬编码角色前缀同理)
|
||||
- **原因**:组件未通过 props 注入路由前缀
|
||||
- **后果**:组件无法跨角色复用;新增角色需修改组件源码
|
||||
|
||||
### 2.8 代码质量维度(P2)
|
||||
|
||||
#### 问题 T-AUDIT-18 | `use-kp-crud.ts` 行数超限
|
||||
|
||||
- **位置**:[hooks/use-kp-crud.ts](file:///e:/Desktop/CICD/src/modules/textbooks/hooks/use-kp-crud.ts) 122 行
|
||||
- **现象**:超过 Hook 上限 80 行
|
||||
- **违反规则**:`project_rules.md` → "自定义 Hook:建议 ≤ 80 行"
|
||||
- **原因**:CRUD 操作集中在一个 Hook
|
||||
- **后果**:维护困难
|
||||
|
||||
#### 问题 T-AUDIT-19 | 表单字段代码重复
|
||||
|
||||
- **位置**:[textbook-form-dialog.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-form-dialog.tsx) 与 [textbook-settings-dialog.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-settings-dialog.tsx)
|
||||
- **现象**:表单字段(title/subject/grade/publisher)几乎完全相同,应抽取共享 `TextbookFormFields` 组件
|
||||
- **违反规则**:DRY 原则
|
||||
- **后果**:字段变更需同步修改两处
|
||||
|
||||
#### 问题 T-AUDIT-20 | 死代码
|
||||
|
||||
- **位置**:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/textbooks/data-access.ts) `verifyKnowledgePointBelongsToChapter`(全项目未使用)
|
||||
- [schema.ts](file:///e:/Desktop/CICD/src/modules/textbooks/schema.ts) `ReorderChaptersSchema`(未使用)
|
||||
- [textbook-reader.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-reader.tsx) `canEdit` deprecated prop
|
||||
- **违反规则**:代码整洁
|
||||
- **后果**:增加维护负担
|
||||
|
||||
#### 问题 T-AUDIT-21 | `textbook-content-panel.tsx` props 膨胀
|
||||
|
||||
- **位置**:[textbook-content-panel.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-content-panel.tsx) 17 个 props
|
||||
- **现象**:props 过多,违反组合优先原则
|
||||
- **违反规则**:`project_rules.md` → "组合优先"
|
||||
- **原因**:编辑态、内容态、选区态未分离
|
||||
- **后果**:组件难以复用和测试
|
||||
|
||||
#### 问题 T-AUDIT-22 | 函数返回类型未显式标注
|
||||
|
||||
- **位置**:`use-knowledge-point-actions.ts`、`use-kp-dialog-state.ts`、`use-text-selection.ts`、`use-graph-data.ts`、`constants.ts`(`getSubjectColor` 等)
|
||||
- **违反规则**:`project_rules.md` → "函数返回值必须显式标注,特别是 `Promise<T>`"
|
||||
- **后果**:类型推导不透明
|
||||
|
||||
### 2.9 性能与可测试性维度(P2)
|
||||
|
||||
#### 问题 T-AUDIT-23 | `textbook-reader.tsx` 命令式 DOM 操作
|
||||
|
||||
- **位置**:[textbook-reader.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-reader.tsx) 第 229-235 行
|
||||
- **现象**:`document.querySelector(\`[data-kp-id="${highlightedKpId}"]\`)` + `el.classList.add/remove`
|
||||
- **风险**:模板字符串选择器存在选择器注入风险;命令式 DOM 操作与 React 数据流冲突
|
||||
- **后果**:难以测试、潜在 XSS
|
||||
|
||||
#### 问题 T-AUDIT-24 | `graph-layout.ts` O(n²) 性能
|
||||
|
||||
- **位置**:[graph-layout.ts](file:///e:/Desktop/CICD/src/modules/textbooks/graph-layout.ts) 第 56、64、89、103 行
|
||||
- **现象**:多次 `knowledgePoints.some(...)` 线性查找
|
||||
- **后果**:大图谱(>100 节点)性能差
|
||||
|
||||
#### 问题 T-AUDIT-25 | `cache()` 包裹影响可测试性
|
||||
|
||||
- **位置**:[data-access.ts](file:///e:/Desktop/CICD/src/modules/textbooks/data-access.ts)、[data-access-graph.ts](file:///e:/Desktop/CICD/src/modules/textbooks/data-access-graph.ts)
|
||||
- **现象**:所有查询用 `cache()` 包裹,测试时需处理 React cache 上下文
|
||||
- **后果**:单元测试需额外 mock
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
基于对 PowerSchool、Veracross、钉钉教育、智学网、班级小管家、ClassIn、智慧树等 K12 产品的调研,教材模块相比行业优秀实践存在以下差距:
|
||||
|
||||
### 3.1 功能差距
|
||||
|
||||
| 差距项 | 行业实践 | 当前实现 | 影响角色 |
|
||||
|--------|----------|----------|----------|
|
||||
| **多角色视图缺失** | admin 全校教材管理、parent 查看子女教材 | 仅 teacher/student 两端 | admin、parent |
|
||||
| **教材版本管理** | 支持多版本教材对比、历史版本回滚 | 无版本概念 | teacher、admin |
|
||||
| **协作编辑** | 多教师协作编辑同一教材(锁机制/CRDT) | 单人编辑,无锁 | teacher |
|
||||
| **离线阅读** | 移动端缓存章节内容离线访问 | 无离线缓存 | student |
|
||||
| **PDF/打印导出** | 章节导出为 PDF、打印友好版式 | 无导出功能 | teacher、student |
|
||||
| **阅读进度追踪** | 记录学生阅读进度、停留时长 | 无进度追踪 | student、teacher |
|
||||
| **教材资源附件** | 章节关联视频、音频、PDF 附件 | 仅 Markdown 文本 | teacher、student |
|
||||
| **知识点掌握度联动** | 图谱节点点击跳转至相关题目/作业练习 | 仅展示掌握度,无跳转练习入口 | student |
|
||||
| **教材评论/批注** | 教师可在章节内添加批注,学生可见 | 无批注功能 | teacher、student |
|
||||
| **智能推荐** | 基于掌握度推荐复习路径 | `analytics.tsx` 仅预留接口,未实现 | student |
|
||||
|
||||
### 3.2 UI/UX 差距
|
||||
|
||||
| 差距项 | 行业实践 | 当前实现 | 影响 |
|
||||
|--------|----------|----------|------|
|
||||
| **骨架屏布局不匹配** | 骨架屏与实际页面结构一致 | [id]/loading.tsx 渲染 grid+sidebar,实际是阅读器布局 | 用户体验割裂 |
|
||||
| **空状态引导** | 空状态提供"创建教材"CTA 按钮 | 仅文字提示 | 转化率低 |
|
||||
| **筛选持久化** | 筛选条件持久化到 URL(nuqs) | 部分持久化,不一致 | 刷新丢失筛选 |
|
||||
| **移动端阅读体验** | 上下滑动阅读、字号调节、夜间模式 | 有 Sheet 抽屉,无字号/主题调节 | 阅读体验差 |
|
||||
| **图谱交互** | 节点拖拽、缩放、小地图、方向键导航 | 有拖拽/缩放,缺方向键导航 | 键盘用户不可达 |
|
||||
|
||||
### 3.3 架构差距
|
||||
|
||||
| 差距项 | 行业实践 | 当前实现 | 影响 |
|
||||
|--------|----------|----------|------|
|
||||
| **配置驱动** | 角色配置决定渲染哪些 Widget | 组件内硬编码角色 URL | 新增角色需改组件 |
|
||||
| **依赖注入** | 通过 Context 注入数据服务 | 组件直接调用 data-access | 难以 mock 测试 |
|
||||
| **错误恢复** | 错误边界提供重试按钮 + 自动上报 | 仅 router.refresh,无上报 | 错误不可追踪 |
|
||||
| **监控埋点** | 关键操作(创建/删除/阅读)埋点 | `analytics.tsx` 仅 stub | 无行为数据 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### 4.1 P0(紧急,安全与数据完整性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 | 影响范围 |
|
||||
|------|------|----------|----------|
|
||||
| T-FIX-01 | T-AUDIT-01 学生端缺 requirePermission | 学生端两个页面添加 `requirePermission(Permissions.TEXTBOOK_READ)` | 2 文件 |
|
||||
| T-FIX-02 | T-AUDIT-02 图谱缺 scope 过滤 | `getKnowledgeGraphDataAction` 学生端按年级 scope 过滤 textbookId | actions.ts |
|
||||
| T-FIX-03 | T-AUDIT-03 class-mastery 缺教师-班级校验 | 传入教师 scope,校验班级归属 | actions.ts |
|
||||
| T-FIX-04 | T-AUDIT-04 deleteChapter 孤儿记录 | 级联删除 `knowledgePointPrerequisites` 表相关记录 | data-access.ts |
|
||||
| T-FIX-05 | T-AUDIT-07/08 前置依赖/创建知识点无 try/catch | 补 try/catch/finally,finally 中复位 loading | knowledge-graph.tsx、textbook-reader.tsx |
|
||||
| T-FIX-06 | T-AUDIT-09 Error Boundary 缺 componentDidCatch | 添加 `componentDidCatch` 记录错误 | section-error-boundary.tsx |
|
||||
|
||||
### 4.2 P1(重要,规范合规)
|
||||
|
||||
| 编号 | 问题 | 改进方向 | 影响范围 |
|
||||
|------|------|----------|----------|
|
||||
| T-FIX-07 | T-AUDIT-05 as 断言 | 用 `NodeProps<GraphLayoutNodeData>` 泛型替代双重断言;data-access 用 `satisfies` | 3 文件 |
|
||||
| T-FIX-08 | T-AUDIT-06 类型与 DB Schema 不一致 | chapterId 改为必填;grade 与 schema 对齐 | types.ts |
|
||||
| T-FIX-09 | T-AUDIT-10 error.tsx 重复 | 抽取 `shared/components/error-boundary.tsx` 共享组件 | 4 文件 → 1 共享 |
|
||||
| T-FIX-10 | T-AUDIT-11 data-access 硬编码英文 | 改用错误码,actions 层翻译 | data-access.ts |
|
||||
| T-FIX-11 | T-AUDIT-12 错误状态值类型不一致 | 统一为 i18n key 或错误码 | use-graph-data.ts |
|
||||
| T-FIX-12 | T-AUDIT-13 章节项无键盘支持 | 添加 `role="button"`/`tabIndex`/`onKeyDown` | chapter-sidebar-list.tsx |
|
||||
| T-FIX-13 | T-AUDIT-14 图标按钮缺 aria-label | 添加 `aria-label` | graph-node-detail-panel.tsx |
|
||||
| T-FIX-14 | T-AUDIT-15/16 loading/error 缺 ARIA | 添加 `role="status"`/`aria-busy`/`role="alert"` | 8 文件 |
|
||||
| T-FIX-15 | T-AUDIT-17 跨模块 URL 硬编码 | 通过 props 注入 `hrefBase`,移除角色前缀 | 4 文件 |
|
||||
| T-FIX-16 | T-AUDIT-18 use-kp-crud 行数超限 | 按 create/update/delete 拆为 3 个子 Hook | use-kp-crud.ts |
|
||||
|
||||
### 4.3 P2(优化,代码质量)
|
||||
|
||||
| 编号 | 问题 | 改进方向 | 影响范围 |
|
||||
|------|------|----------|----------|
|
||||
| T-FIX-17 | T-AUDIT-19 表单字段重复 | 抽取 `TextbookFormFields` 共享组件 | 2 文件 |
|
||||
| T-FIX-18 | T-AUDIT-20 死代码 | 删除 `verifyKnowledgePointBelongsToChapter`、`ReorderChaptersSchema`、`canEdit` prop | 3 文件 |
|
||||
| T-FIX-19 | T-AUDIT-21 props 膨胀 | 拆分为编辑态/内容态/选区态 Context | textbook-content-panel.tsx |
|
||||
| T-FIX-20 | T-AUDIT-22 返回类型未标注 | 显式标注所有 Hook/工具函数返回类型 | 5 文件 |
|
||||
| T-FIX-21 | T-AUDIT-23 命令式 DOM 操作 | 改用 React 状态驱动高亮 | textbook-reader.tsx |
|
||||
| T-FIX-22 | T-AUDIT-24 O(n²) 性能 | 预建 Set 替代 `some()` | graph-layout.ts |
|
||||
| T-FIX-23 | T-AUDIT-25 cache() 可测试性 | 导出非 cache 版本供测试 | data-access.ts |
|
||||
|
||||
### 4.4 中长期计划(P3,功能增强)
|
||||
|
||||
| 编号 | 功能 | 优先级 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| T-FEAT-01 | admin/parent 端教材视图 | 中 | 复用现有组件,新增配置驱动 |
|
||||
| T-FEAT-02 | 教材版本管理 | 低 | 新增 `textbook_versions` 表 |
|
||||
| T-FEAT-03 | PDF/打印导出 | 中 | 服务端生成 PDF |
|
||||
| T-FEAT-04 | 阅读进度追踪 | 中 | 新增 `reading_progress` 表 |
|
||||
| T-FEAT-05 | 教材资源附件 | 中 | 章节关联 files 模块 |
|
||||
| T-FEAT-06 | 智能推荐学习路径 | 低 | 接入 AI 模块 |
|
||||
| T-FEAT-07 | 图谱方向键导航 | 低 | React Flow 键盘交互 |
|
||||
| T-FEAT-08 | 监控埋点接入 | 中 | 实现 `analytics.tsx` 真实逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
### 5.1 需要更新的节点
|
||||
|
||||
| 文档 | 节点 | 修改内容 |
|
||||
|------|------|----------|
|
||||
| 004 § 2.5 | 已知问题 | 新增 v3 审计发现:学生端缺 requirePermission、图谱缺 scope 过滤、deleteChapter 孤儿记录、error.tsx 重复、a11y 缺失等 |
|
||||
| 004 § 2.5 | 文件清单 | 更新行数(如 use-kp-crud 拆分后行数变化) |
|
||||
| 005 textbooks.knownIssues | knownIssues | 同步上述新发现 |
|
||||
| 005 textbooks.files | files | 同步行数变化 |
|
||||
|
||||
### 5.2 无需修改的部分
|
||||
|
||||
- 导出函数清单:本次审计未发现新增/删除/重命名导出函数
|
||||
- 依赖关系:未新增跨模块依赖
|
||||
- 权限点:未新增权限点
|
||||
- 数据库表:未新增表(中长期计划中的新表待实施时再同步)
|
||||
|
||||
---
|
||||
|
||||
## 附录:审计方法说明
|
||||
|
||||
- **审计基准**:2026-06-24 代码库快照
|
||||
- **审计工具**:人工阅读全部 26 个模块文件 + 13 个 app 层文件
|
||||
- **覆盖维度**:安全/权限、数据完整性、类型安全、错误处理、i18n、a11y、跨模块耦合、代码质量、性能、可测试性
|
||||
- **未覆盖**:E2E 测试、性能压测、安全渗透测试(建议后续补充)
|
||||
Reference in New Issue
Block a user