Files
NextEdu/docs/architecture/audit/archive/adaptive-practice-audit-report.md

46 KiB
Raw Blame History

专项练习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 架构影响地图005 架构数据


一、现有实现概要

1.1 文件分布与代码量

文件 行数 职责 规范符合性
types.ts 143 联合类型定义PracticeType/PracticeSourceMeta 等)
schema.ts 74 Zod 输入验证
data-access.ts 616 学生端 CRUD + 自动判分 ≤800
data-access-strategy.ts 343 四种出题策略
data-access-analytics.ts 634 教师/年级宏观数据分析 ⚠️ 接近 800建议拆分
actions.ts 267 7 个 Server Actions
components/practice-starter.tsx 263 练习发起器
components/practice-session-view.tsx 550 答题界面(含 QuestionCard/AnswerInput/AnswerResult/PracticeResultView ⚠️ 超 500需拆分
components/practice-history.tsx 95 练习历史列表
components/practice-stats-cards.tsx 73 学生端统计卡片
components/practice-overview-stats-cards.tsx 99 教师/年级统计卡片
components/class-practice-comparison-table.tsx 88 班级对比表
components/practice-type-breakdown-chart.tsx 123 类型分布柱状图
components/class-knowledge-point-weakness-chart.tsx 144 知识点薄弱度柱状图
components/student-practice-ranking-table.tsx 112 学生排名表
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 缺失 parentADAPTIVE_PRACTICE_READ 权限但无页面)

1.3 数据流与依赖关系

模块内app/page.tsxmodules/adaptive-practice/{actions, data-access, data-access-analytics}shared/{db, lib/auth-guard, types}

跨模块

  • modules/adaptive-practice/data-access-analytics.tsmodules/classes/data-accessgetActiveStudentIdsByClassId / getClassNameById / getClassesByGradeId / getClassIdsByGradeIds / getStudentIdsByClassIds 合规
  • modules/adaptive-practice/data-access-analytics.tsmodules/users/data-accessgetUserIdsByGradeId / getUserNamesByIds 合规
  • app/(dashboard)/student/error-book/student-error-book-list-client.tsxmodules/adaptive-practice/actionscreatePracticeSessionAction app 层组合合规
  • app/(dashboard)/teacher/practice/page.tsxmodules/error-book/components/class-filter ⚠️ 跨模块 UI 复用 + 字段强转 hack
  • modules/adaptive-practice/data-access-strategy.tsshared/db/schemaquestions / questionsToKnowledgePoints / knowledgePointMastery / practiceAnswers 合规

1.4 架构图同步状态

004_architecture_impact_map.md §2.30 与 005_architecture_data.jsonadaptivePractice 节点已完整覆盖:

  • DB SchemapracticeSessions / practiceAnswers、Server Actions7 个、Data Access、4 种出题策略、教师/年级宏观数据分析
  • 依赖矩阵、权限点、DataScope 行级权限、自动判分规则

架构图遗漏

  • 未记录 parent 角色路由(因为页面本身缺失,架构图未列出 /parent/practice
  • 未记录 error-book 模块通过 onStartVariantPractice props 注入的解耦关系
  • 未记录 practice-starter.tsxpractice-session-view.tsxpractice-history.tsx 等组件的 props 接口
  • 未记录 identifyWeakKnowledgePoints 这个未被调用的导出函数

二、现存问题与原因分析

2.1 架构与耦合问题

P0-1 跨模块 UI 复用通过字段强转 hack 实现

内容
位置 teacher/practice/page.tsx L31-32, L108-116
问题 教师练习分析页直接 import @/modules/error-book/components/class-filter@/modules/error-book/types,并通过字段重命名把 TeacherClassPracticeOverview 强转为 ClassErrorOverviewtotalErrorItems ← totalSessionsaverageMasteryRate ← averageAccuracydueReviewCount: 0。语义完全错位,"练习数"被当成"错题数"展示。
规则 违反"模块间只能通过对方 data-access 通信"和"避免 as 断言"。
后果 任何一方修改 ClassErrorOverviewClassFilter 字段都会破坏练习分析页;筛选器 tooltip 显示"错题数"误导用户。

P0-2 PracticeStarter 硬编码路由跳转与 Action 直调

内容
位置 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
问题 单文件包含 PracticeSessionView / QuestionCard / AnswerInput / AnswerResult / PracticeResultView 五个组件 + extractOptions 辅助函数。AnswerInput 内部对 4 种题型的渲染逻辑高度相似却重复编写。
规则 违反"React 组件建议 ≤ 500 行"和"最大化复用:识别共用 UI 块抽象为泛型组件"。
后果 难以单测、难以独立复用 QuestionCard(例如在错题本详情弹窗中预览变式题)。

2.2 权限与安全问题

P0-4 后端 completePracticeSession 不校验是否全部题已答

内容
位置 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 L320-410
问题 检查 answerRecord.status === "answered" 后再更新,但中间无事务/行锁。学生快速双击提交按钮可绕过检查,导致同一题被二次判分,updateSessionStats 重复累加 answeredQuestionscorrectCount
规则 违反"安全性Server Action 二次校验"。
后果 统计数据被双重累加,正确率失真。

P0-6 getPracticeSessionsAction 未校验 studentId 与 ctx 的强一致性

内容
位置 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 L16-23, management/grade/practice/error.tsx L16-23
问题 "专项练习分析"、"加载练习分析数据时发生错误"、"加载失败"、"请刷新页面重试..."、"年级专项练习总览"、"加载年级练习数据时发生错误" 全部硬编码中文。
规则 违反"所有用户可见文本必须适配 i18n使用 next-intl提取翻译键"。
后果 英文环境下显示中文,破坏国际化。

P0-8 actions.ts 错误消息硬编码中文

内容
位置 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 L336, L340, L352, L356, L382
问题 throw new Error("练习会话不存在或无权访问") 等中文消息直接抛给上层,最终被 handleActionError 包装后展示给用户。
规则 违反"所有用户可见文本必须适配 i18n"。
后果 与 P0-8 同。

P0-10 业务数据写入翻译文本

内容
位置 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 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 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 L325-343
问题 类型守卫仅检查 "errorBookItemIds" in meta 等 key 存在性,不验证 errorBookItemIdsstring[]sourceQuestionIdsstring[]。客户端可传 { errorBookItemIds: 123, sourceQuestionIds: null } 通过守卫,随后 inArray(questions.id, sourceQuestionIds) 抛 SQL 错误。
规则 违反"未知类型用 unknown 并做类型守卫"。
后果 SQL 异常泄露内部信息。

P1-4 practice-session-view.tsx L360 数组断言

内容
位置 practice-session-view.tsx L360 userAnswer as string[]
问题 多选题把 userAnswer 强转为 string[],但 userAnswer 类型为 unknown
规则 违反"禁止 as 断言"。
后果 类型不安全。

2.5 业务逻辑缺陷

P0-11 "错题变式"策略实际不做变式

内容
位置 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 L135-180, L298-319
问题 selectForWeakChapter 接收 sourceMeta.weakKnowledgePointIds(要求前端传入),未调用同文件已定义的 identifyWeakKnowledgePoints(studentId, chapterId) 函数自动识别。WeakChapterSourceMeta.chapterId 字段定义了但策略中未使用。用户必须先在另一处查看薄弱知识点再手动选择,体验割裂。
规则 违反"组合优先:逻辑复用一律抽取为自定义 hooks"和"最大化复用"。
后果 "薄弱章节"功能名不副实;identifyWeakKnowledgePoints 成为死代码。

P0-13 createPracticeSession 返回空 sessionId 表示失败

内容
位置 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 L116, L173, L225
问题 知识点专项、薄弱章节、AI 推荐三种策略都用 sql\RAND()`。MySQL ORDER BY RAND()` 在大表上会全表扫描排序,题库上万题时性能急剧下降。
规则 违反"性能:优先使用 React Server Components 获取初始数据"。
后果 大题库下出题延迟可达数秒。

P1-6 getTeacherClassPracticeOverviews / getGradeClassPracticeComparison N+1 查询

内容
位置 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 L81-107
问题 handleSubmit 失败仅 toast.error,不保留失败状态、不提供重试按钮。学生网络抖动时需要手动重新选择答案再提交,且因为 setResults 未更新UI 上仍显示"未作答"。
规则 违反"明确处理空数据、无权限、网络异常等边界状态"。
后果 网络异常时学生困惑、流失答题意愿。

P0-15 缺少细粒度 React Error Boundary

内容
位置 teacher/practice/page.tsx 全文
问题 教师分析页一个数据区块失败会导致整页回退到 error.tsx。比如 ClassKnowledgePointWeaknessChart 数据查询失败,整个页面(含已加载的统计卡片、对比表)一起消失。
规则 违反"每个独立的数据区块必须用 React Error Boundary 包裹"。
后果 局部错误导致整页不可用。

P0-16 缺少流式渲染与骨架屏细粒度

内容
位置 teacher/practice/page.tsx L122-133
问题 Promise.all([...]) 阻塞所有数据加载完成才渲染任何内容,仅外层 Suspense 包裹整页。无区块级 Suspense + 骨架屏。
规则 违反"异步数据使用 React Suspense + 骨架屏"和"支持流式渲染"。
后果 首屏白屏时间长达数秒。

2.7 可测试性缺失

P1-7 自动判分纯函数未导出

内容
位置 data-access.ts L517-616
问题 autoGradeAnswer / extractChoiceCorrectIds / extractJudgmentCorrectAnswer / normalizeAnswerToIds / normalizeAnswerToBool 全部为内部函数,未 export。无法单测,判分正确性无保障。
规则 违反"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks与 UI 分离;导出清晰的接口类型以便 mock"。
后果 判分 bug 难以回归。

P1-8 出题策略未单独导出

内容
位置 data-access-strategy.ts L40-232
问题 selectForErrorVariant / selectForKnowledgePoint / selectForWeakChapter / selectForAiRecommended 均未导出。仅 selectQuestionsForPractice 入口可测,无法针对单策略测试。
规则 违反"导出清晰的接口类型以便 mock"。
后果 策略调整需要端到端测试。

2.8 可访问性a11y缺失

P1-9 题目内容用 JSON.stringify 展示

内容
位置 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 L192-204
问题 <input type="checkbox"> 原生元素而非 shadcn Checkbox,且无 aria-label。屏幕阅读器无法识别知识点名称。
规则 违反"a11yARIA 属性"。
后果 视障用户无法使用。

2.9 Parent 角色路由缺失

P0-17 parent 有权限无页面

内容
位置 缺失 src/app/(dashboard)/parent/practice/
问题 parent 角色在 rolePermissions 中拥有 ADAPTIVE_PRACTICE_READactions.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 全量提取 i18nerror.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

// 模块对外的数据服务抽象(接口)
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 Providercontext/practice-service-provider.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 层注入

// 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 抽取

// 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 扩展)

{
  "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 错误返回结构化错误码

// 不再返回中文 message返回 errorCode 由前端翻译
return { success: false, errorCode: "session_not_found" }

// 前端
const message = t(`errors.${res.errorCode}`)

5.4 最大化复用:泛型组件与配置驱动

5.4.1 角色配置驱动渲染

// 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 通用统计卡片泛型组件

// 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

// shared/components/section-error-boundary.tsx (复用 dashboard 模块已有)
<SectionErrorBoundary fallback={<SectionErrorFallback />}>
  <Suspense fallback={<ClassComparisonSkeleton />}>
    <ClassPracticeComparisonTable data={data} />
  </Suspense>
</SectionErrorBoundary>

5.5.2 答题失败重试

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)
    // 保留 selectedAnswerUI 显示重试按钮
  }
}

// 渲染
{submitError ? (
  <RetryBanner error={submitError} onRetry={() => handleSubmit(userAnswer)} />
) : null}

5.6 可测试性

5.6.1 纯函数抽取(lib/grading.tslib/source-meta.ts

// 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

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

// 未来新增"教研组长"角色
{ 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 监控埋点

// 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 接口 补充 PracticeStarterPropsPracticeSessionViewProps 等关键接口定义
004 §2.30 identifyWeakKnowledgePoints 导出函数 当前为死代码,重构后将被策略调用
005 adaptivePractice.exports 纯函数 lib 导出 新增 lib/grading.tslib/source-meta.tslib/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 SchemapracticeSessions / practiceAnswers字段定义不变
  • 权限点ADAPTIVE_PRACTICE_READ / ADAPTIVE_PRACTICE_MANAGE不变
  • 现有 Server Actions 的对外签名不变(仅内部实现增强校验)

七、实施清单(本次执行)

7.1 P0 修复项(本次完整实施)

  • P0-修复-1completePracticeSession 增加答题完整性校验 + submitPracticeAnswer 事务化
  • P0-修复-2Actions 增加 class_taught / grade_managed dataScope 下 studentId 归属校验
  • P0-修复-3提取 shared/components/class-filter,移除 teacher/practice 对 error-book 的字段强转 (注:实际命名为 shared/components/class-filter.tsx,非 practice-class-filter,因属通用共享组件)
  • P0-修复-4selectForErrorVariant 重命名为 selectForErrorReview错题重做UI 文案改为"错题重做" 实现层保留函数名UI 文案已更新)
  • P0-修复-5selectForWeakChapter 自动识别薄弱知识点 chapterId 改为可选,未传时跨所有章节自动识别)
  • P0-修复-6createPracticeSession 失败抛 PracticeQuestionNotFoundError
  • P0-修复-7全量 i18nerror.tsx、actions.ts、data-access.ts
  • P0-修复-8sourceMeta.reason 改为枚举值 AiRecommendedReason 类型 + reasons.* 翻译键)
  • P0-修复-9practice-session-view.tsx 拆分 + 引入 QuestionRenderer (拆分为 5 个子组件)
  • P0-修复-10答题失败重试 + 区块级 ErrorBoundary + Suspense WidgetBoundary 包裹数据区块)
  • P0-修复-11新增 /parent/practice 路由 page + loading + errorPracticeServiceProvider 注入WidgetBoundary 隔离)

7.2 P1 修复项(本次完整实施)

  • P1-修复-1类型守卫替换 as 断言 + Zod 判别式 schema
  • P1-修复-2practice-session-view.tsx L360 类型守卫
  • P1-修复-3出题策略 SQL 优化 + N+1 查询合并 data-access-analytics.ts 单 SQL GROUP BY class_id
  • P1-修复-4导出纯函数 + 增加 vitest 单测 (提取 lib/grading.ts / lib/source-meta.ts / lib/type-guards.ts;单测文件待后续补齐)
  • P1-修复-5PracticeStarter Provider 注入 services/practice-service.tsx + usePracticeService() + usePracticeAnalytics()
  • P1-修复-6a11y 修复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 (见 §六)