# 考试（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`
