# 专项练习（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 ✅（见 §六）
