# 全链路死角审查与验收清单

> 范围：`/home/zyw/Downloads/ai-lesson-prep`（Next.js 14）。已实证跑通：教案生成 → `plan_confirmed` → PPT 生成 → `ppt_confirmed` → 导出 `plan_docx` / `ppt`。  
> 本审查只覆盖剩余步骤：材料生成/确认/`completed`、其余导出类型、教研样例发布、学习通五步、三类课时（无主案例讲授课 / 案例课 / 实验课）。  
> 证据均为代码行号；判定口径：**可行** = 按 UI 正常点能走通；**有风险** = 能走通但会空文件 / 与 UI 不符 / 需绕路；**死路** = 按 UI 操作会 400/按钮永灰，该步骤不可达。

状态机（单 RANK，不是真并行）：

`not_started → plan_draft → plan_confirmed → ppt_draft → ppt_confirmed → materials_draft → completed`

`src/lib/prepare/state.ts:3-4, 22-30, 50-81`。`generate_ppt` / `generate_materials` 用 `maxStatus` 只升不降；`completed` 除「重生成教案」外锁死。

---

## 0. 总表（先看结论）

| # | 步骤 | 无主案例讲授课 | 案例课 | 实验课 | 判定 |
|---|---|---|---|---|---|
| 1 | 材料生成 → 确认 → `completed` | **死路（卡在生成教案）**，绕开 G2 后材料段**可行** | **可行**（有主案例） | **死路（同 G2）**，绕开后材料段**可行/有风险** | 见 §1 |
| 2a | 导出 `question_bank` | 有材料才行；无习题则空表 | 同左 | 同左；实验更易空 | 见 §2 |
| 2b | 导出 `question_bank_xlsx` | 同上 | 同上 | 同上 | 见 §2 |
| 2c | 导出 `materials_teacher` / `materials_student` | 有材料草稿即可（未确认会警告） | 同左 | 同左 | 见 §2 |
| 2d | 导出 `activity_list` | **可行**（只要求已确认教案） | 同左 | 同左 | 见 §2、§4 |
| 2e | 导出 `toolkit` | 同题库，须有材料 | 同左 | 同左 | 见 §2 |
| 3 | `publish-sample` | 须先 `completed`；脱敏有 422 边角 | 同左 | 同左 | 见 §3 |
| 4 | 学习通五步 ↔ `activity_list` | 无硬依赖 | 同左 | 同左 | 见 §4 |
| 5 | 实验课教案/PPT/材料/题库 | G1 已不再因无目标卡死；G2/课型硬编码仍卡 | — | 见左 | 见 §5 |

---

## 1. 材料生成 → 确认（`step=materials`）→ `completed`

### 1.1 主路径可达性

| 字段 | 内容 |
|---|---|
| **判定** | **可行**（前提：已 `plan_confirmed`，且本课能生成出非空 `exercises` 或 `cases`） |
| **证据** | 状态机 `confirm_materials`：`src/lib/prepare/state.ts:73-75`（当前 RANK ≥ `materials_draft` 才进 `completed`）。后端确认：`src/app/api/lesson-plans/[id]/confirm/route.ts:97-115`（无 `draft.materials` → 400 `need_materials`；RANK < `materials_draft` → 400 `need_materials_draft`；G1 失败 → 400 `g1_no_objectives`，同文件 67-74）。生成材料门槛：`src/lib/ai/generate.ts:536-544` + `src/lib/prepare/state.ts:42-44`（须 `plan_confirmed` 及以上）。落库升态：`src/lib/prepare/persist.ts:71-81`（`generate_materials` → `maxStatus(..., materials_draft)`）。废弃一键完成：`confirm/route.ts:116-124`（`step=complete` 恒 400 `complete_removed`）。 |
| **UI 确认按钮** | `src/app/prepare/[classId]/lesson-dev-panel.tsx:954-977`。`disabled` 条件：`busy` **或** `!bundle.materials` **或** `status !== "materials_draft"` **或** `g1` 不通过。按钮文案「确认材料（完成备课）」，只在「材料」页签出现。 |
| **修复建议** | 不改主路径。把 `status !== "materials_draft"` 的 title 写全（G1 / 空材料）。`completed` 后若允许补确认材料，按钮应在 `completed && draft.materials 新于 confirmed` 时重新点亮，或提供「更新确认稿」。 |
| **验证方式** | `plan_confirmed` 后 `POST /api/lesson-plans/generate {step:"materials",run:true}` → 任务 `done` → `GET` 教案 `status=materials_draft` → `PUT .../confirm {step:"materials"}` → `status=completed`。UI：材料页按钮从灰→可点→完成后变灰。 |

### 1.2 UI 与后端对「空材料」不一致

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（UI 更严；直打 API 可把空材料标成 `completed`） |
| **证据** | UI 点击处理 `lesson-dev-panel.tsx:415-425`：`exercises.length===0 && cases.length===0` 则前端报 `need_materials`，**不发请求**。后端 `confirm/route.ts:99-104` 只判断 `!draft.materials`（空对象 `{}` 或 `{exercises:[]}` 也算有）。Schema `src/lib/ai/schema.ts:429-432`：`exercises` 必须是数组，**允许空数组**。材料页列表 `lesson-dev-panel.tsx:982-1003`：无习题时显示「尚未生成材料」，但只要 `materials` 对象在且 `status=materials_draft`，确认按钮仍可点（空习题+空案例时点下去才报错）。 |
| **修复建议** | 后端与 UI 对齐：确认时要求 `exercises.length+cases.length≥1`（或实验课允许 `worksheets` 等字段）。Schema 对空 `exercises` 标 error，避免 flagged 仍落库升态。 |
| **验证方式** | 手工把 `draft.materials` 写成 `{exercises:[],cases:[]}`，`status=materials_draft`：UI 点确认应失败；`curl PUT confirm step=materials` 今日会 **201/200 且 status=completed**（这就是不一致）。 |

### 1.3 确认按钮对状态名过严（重生成教案后的假死）

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（材料还在草稿里，按钮永久灰，直到再点一次「生成材料草稿」） |
| **证据** | `nextStatus("completed"|"materials_draft", "generate_plan")` → `plan_draft`：`state.ts:57-61`。`persist.ts:59-64` 重生成只改 `draft.plan`，**不删** `draft.materials`。再确认教案 → `plan_confirmed`（RANK 2）。此时 `confirm/route.ts:106-114` 因 RANK < `materials_draft` 拒绝；UI `lesson-dev-panel.tsx:962` 因 `status !== "materials_draft"` 禁用按钮。必须再跑一次 `generate_materials` 把状态抬回去。`completed` 后重生成材料：`state.ts:57` 状态锁在 `completed`，按钮同样灰；导出读 **confirmed 优先**（`exporters/run.ts:145-147`），新草稿不会进导出。 |
| **修复建议** | 确认材料的准入改为「`draft.materials` 非空 **且** RANK ≥ `plan_confirmed`」，升态用 `maxStatus(cur, completed)`；或重生成教案时若已有材料则 `nextStatus` 回到 `materials_draft` 而非 `plan_draft` 一条路走到底。`completed` 后更新材料：允许 `confirm_materials` 覆盖 `confirmed.materials`。 |
| **验证方式** | `completed` → 再生成教案 → `plan_draft` → 确认教案 → `plan_confirmed` → 材料页确认按钮应为可点（今日不可点）→ 再点「生成材料草稿」后应变为可点。 |

### 1.4 无主案例讲授课 / 实验课：卡在「生成教案」，材料段根本进不去

| 字段 | 内容 |
|---|---|
| **判定** | **死路**（UI 全链路；与「材料」步骤的关系是前置被切） |
| **证据** | UI 生成一律 `lessonType: "案例讨论"`：`lesson-dev-panel.tsx:224-243`（plan/materials/ppt 三次调用同一函数）。生成 API 的 G2 默认值也是 `"案例讨论"`：`src/app/api/lesson-plans/generate/route.ts:66-78`。G2：`src/lib/prepare/g2.ts:114-126`——**只要请求课型是案例型，无主案例就 400 `g2_no_main_case`**，不管模板偏好是不是「讲授/实验」。UI 前置拦截只用 `lesson.requiresMainCase`（`wizard-data.ts:128` ← `needsMainCase(preferred)`），讲授课/实验课 `requiresMainCase=false`，按钮可点，请求照样被 G2 打回。课型下拉默认同样是「案例讨论」：`lesson-dev-panel.tsx:1216-1217`。 |
| **三类课时** | **案例课**：向导会要主案例（`lesson-dev-panel.tsx:226-228, 608`），选了就能过 G2，材料段可达。**无主案例讲授课 / 实验课**：UI 点「生成教案」→ 400，到不了 `plan_confirmed`，后续材料/完成/发布全断。直打 API 并显式传 `lessonType:"讲授"` 或 `"实验"` 可绕开（`g2.ts:116-118`：requested 非案例型则 `mustBeCase=false`）。 |
| **修复建议** | `runGenerate` 传 `lesson.preferredLessonTypes[0]`（或用户在表单选的 `plan.lesson_type`），禁止写死「案例讨论」。`generate/route.ts:71` 去掉 `?? "案例讨论"`，无课型时走模板偏好（G2 的 `uniqueTypes.every(isCaseLessonType)` 分支）。 |
| **验证方式** | 理工班/未选主案例的讲授课、实验1：UI 点生成教案，今日必 `g2_no_main_case`。改 body `lessonType:"讲授"` 后应 `201` 并落到 `plan_draft`。 |

### 1.5 并行 PPT 后仍能确认材料（不是死路）

| 字段 | 内容 |
|---|---|
| **判定** | **可行** |
| **证据** | `plan_confirmed` → 先生成并确认 PPT → `ppt_confirmed`（RANK 4）→ 再生成材料 → `maxStatus(ppt_confirmed, materials_draft)=materials_draft`（`state.ts:70-72, 36-39`）。此时确认按钮条件满足。先材料后 PPT：`generate_ppt` 从 `materials_draft` 不降级（`state.ts:64-66`）；`confirm_ppt` 的 `maxStatus(materials_draft, ppt_confirmed)` 仍是 `materials_draft`（`state.ts:67-69`），与 UI 提示「PPT 确认不回退」一致（`lesson-dev-panel.tsx:389-391, 979-980`）。**可以不确认 PPT 就完成备课**：`completed` 不要求 `ppt_confirmed`。 |
| **修复建议** | 无需改可达性。若产品要求「完成=教案+材料+PPT 都确认」，应在 `confirm_materials` 增加 `generation_meta.ppt_confirmed===true` 闸门（今日没有）。 |
| **验证方式** | 两条序：A. 教案确认→材料→确认材料→`completed`（无 PPT）。B. 教案确认→PPT 确认→材料→确认材料→`completed`。均应成功。 |

---

## 2. 导出类型前置校验（`src/lib/exporters/run.ts`）

公共入口：`runLessonExport`。弱教案闸：`run.ts:136-142`——`confirmed` 里既没有 `lesson_title` 也没有 `objectives` 数组 → 400 `need_plan_confirmed`。**空数组 `objectives:[]` 也算「有数组」，会放行。** PPT 导出读 **仅 confirmed**（`run.ts:144, 281-286`）；材料读 **confirmed 否则 draft**（`run.ts:145-149`）。

`needMaterials()`：`run.ts:95-103`（`materials_teacher|materials_student|question_bank|question_bank_xlsx|toolkit`）。`activity_list` **不在其中**。`requireMaterials()`：`run.ts:271-278`（无材料 → 400 `need_materials`，文案写的是「须先生成材料再导出题库」，材料 docx 另有文案 `run.ts:350-354`）。

导出按钮 UI **只看** `busy \|\| !planId`（`lesson-dev-panel.tsx:904-927`），不看状态、不看是否已确认，点下去才 400。与文案「导出基于已确认内容」（900-901 行）不符。

### 2.1 `ppt`（已实证，对照用）

| 字段 | 内容 |
|---|---|
| **判定** | **可行**（须 PPT 已写入 `confirmed_content.ppt.slides`） |
| **证据** | `run.ts:281-295`：无 slides → 400 `need_ppt_confirmed`；再跑 `validateGenerationPayload("ppt")`，失败 → 400 `ppt_hard_constraint`（页级：要点≤5 / 单条≤20 字 / 讨论课争议页+记录页，`schema.ts:274-396`，上限 `PPT_MAX_SLIDES=16` 见 `src/lib/ai/types.ts:53`）。**不接受 draft PPT。** 不要求 `status===ppt_confirmed`（材料先行导致状态停在 `materials_draft`/`completed` 时，只要确认过 PPT 仍能导出——与面板 979-980 行说明一致）。 |
| **缺了会怎样** | 未确认 PPT：400 `need_ppt_confirmed`。硬约束失败：400，带 `issues`。 |
| **修复建议** | 导出按钮在 `!confirmed.ppt.slides` 时禁用，避免空点。 |
| **验证方式** | `ppt_confirmed` 或 `materials_draft` 且 meta `ppt_confirmed=true` 时导出 201；仅 draft PPT 应 400。 |

### 2.2 `plan_docx`（已实证）

| 字段 | 内容 |
|---|---|
| **判定** | **可行** |
| **证据** | 只走弱教案闸 `run.ts:140-142, 322-348`。用 confirmed plan。不要求材料/PPT。 |
| **缺了会怎样** | 从未确认教案：400 `need_plan_confirmed`。 |
| **修复建议** | 弱闸改为「`checkG1(plan)` 通过」或 `statusRank>=plan_confirmed`，避免空 `objectives:[]` 漏网。 |
| **验证方式** | `plan_confirmed` 后导出 201（已实证）。 |

### 2.3 `materials_teacher` / `materials_student`

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（不强制「已确认材料」；未确认按草稿导出并警告） |
| **证据** | `needMaterials` 含二者：`run.ts:95-103`。无任何材料：`run.ts:349-354` 400 `need_materials`。有 draft 无 confirmed：`run.ts:147-148` `warnings.push("材料尚未确认，本次按草稿导出")`，**仍写 docx**。教师版含答案、学生版隐藏：`materials-docx.ts:59-70`。不要求 `completed`。 |
| **缺了会怎样** | 无材料：400。未确认教案：若 confirmed 无 plan，先被 `need_plan_confirmed` 拦住。未确认材料：成功 + warning。 |
| **三类课时** | 讲授课/实验课只要材料生成成功即可导出，不必先完成备课。案例课同。 |
| **修复建议** | 若产品要求「确认后才导出」，在 `needMaterials` 分支改为「无 confirmed 材料则 400」，draft 仅警告不够。UI 按钮按 `bundle.materials` 禁用。 |
| **验证方式** | `materials_draft` 导出教师版 → 201 且 `warnings` 含「尚未确认」。`completed` 后再导出 → 无该警告。无材料 → 400 `need_materials`。 |

### 2.4 `question_bank`

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（有材料就成功；**无习题也成功，得到空表**） |
| **证据** | `run.ts:372-398`：`requireMaterials()` 后写 4 个文件：CSV（P7 列）、xlsx、导入说明、**顺带一份 `activity_list`**。行数据只来自 `materials.exercises`：`question-bank.ts:18-37, 75-107`。`cases` 不进题库。不要求材料已确认（draft 回落 + 警告，同 147-148）。不要求 PPT。 |
| **缺了会怎样** | 无材料对象：400 `need_materials`。有材料无习题：201，CSV 仅表头、xlsx「题库」表只有表头。未确认教案：可能先 400 `need_plan_confirmed`。 |
| **修复建议** | `exercises.length===0` 时 400 `empty_question_bank` 或强制 warning。UI 提示「题库来自材料习题，案例讨论题不入库」。 |
| **验证方式** | 有习题：xlsx 行数 = 1+N。无习题有案例：201 但行数=1（仅表头）。 |

### 2.5 `question_bank_xlsx`

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（同 2.4） |
| **证据** | `run.ts:399-419`：xlsx + 学习通 CSV + 导入说明。**不附带** `activity_list`（与 `question_bank` 不同）。同样 `requireMaterials()`，同样不查习题是否为空。 |
| **缺了会怎样** | 同 2.4。 |
| **修复建议** | 同 2.4。 |
| **验证方式** | 与 2.4 对照文件集合：本类型无 `activity_list_txt`。 |

### 2.6 `activity_list`

| 字段 | 内容 |
|---|---|
| **判定** | **可行**（只要求已确认教案；与五步勾选无关） |
| **证据** | **不在** `needMaterials()`：`run.ts:95-103, 420-432`。写活动清单 + 课件包清单。清单正文 `question-bank.ts:253-320`：目标来自 **confirmed plan**；讨论/作业/测验可来自 `lesson_events` 与 **可选** materials。无材料时各类可出现「本课教案未单独列出，可按资料上传或跳过」（310-311）。不读 `chaoxing_five_steps`。 |
| **缺了会怎样** | 未确认教案：400 `need_plan_confirmed`。未生成材料：**仍成功**，活动建议偏空。未勾五步：**仍成功**。 |
| **修复建议** | 保持「照做清单不依赖勾选」。UI 标明「不要求材料/五步」。若希望清单饱满，在无材料时 warning。 |
| **验证方式** | `plan_confirmed` 且无材料：201，正文含章节目录与占位活动。`completed` 且有习题：测验/作业条数随 `exercises` 增加。 |

### 2.7 `toolkit`（一键交换物包）

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（须材料；内含空题库仍打包成功；含五步记录快照） |
| **证据** | `run.ts:433-466`：`requireMaterials()` 后逐个 save xlsx/csv/学习通 csv/guide/`activity_list`/课件包清单/`chaoxing_checklist`，再 zip。checklist 来自 `parseChaoxingSteps(gen.chaoxing_five_steps)`（`run.ts:222-233, 441`），**全未勾也能导出**（记录里写「未勾选」）。不要求 PPT、不要求 `completed`、不要求材料已确认。 |
| **缺了会怎样** | 无材料：400 `need_materials`。未勾五步：zip 内仍有「-五步勾选记录.txt」。无习题：题库文件空表但仍进包。 |
| **修复建议** | 与题库同样处理空习题。产品若要求「完成备课才出交换物」，加 `status===completed`。 |
| **验证方式** | `materials_draft`：201 + warning「尚未确认」。无材料：400。zip 内应有 7 个成员文件 + toolkit 自身记录。 |

### 2.8 前置校验对照表

| 导出 type | 须已确认教案 | 须已有材料 | 须已确认材料 | 须已确认 PPT | 缺前置 |
|---|---|---|---|---|---|
| `ppt` | 弱闸 | 否 | 否 | **是（confirmed slides + schema）** | `need_plan_confirmed` / `need_ppt_confirmed` / `ppt_hard_constraint` |
| `plan_docx` | 弱闸 | 否 | 否 | 否 | `need_plan_confirmed` |
| `materials_teacher` | 弱闸 | **是**（draft 可） | 否（警告） | 否 | `need_materials` |
| `materials_student` | 弱闸 | **是**（draft 可） | 否（警告） | 否 | `need_materials` |
| `question_bank` | 弱闸 | **是** | 否（警告） | 否 | `need_materials`；空习题仍 201 |
| `question_bank_xlsx` | 弱闸 | **是** | 否（警告） | 否 | 同上 |
| `activity_list` | 弱闸 | **否** | 否 | 否 | 仅 `need_plan_confirmed` |
| `toolkit` | 弱闸 | **是** | 否（警告） | 否 | `need_materials` |

---

## 3. 教研样例 `publish-sample` / `research_samples` / 脱敏

### 3.1 发布闸门与落库

| 字段 | 内容 |
|---|---|
| **判定** | **可行**（须 `completed`）；若干 **边角失败** 见下 |
| **证据** | API：`src/app/api/lesson-plans/[id]/publish-sample/route.ts:38-48`。核心：`src/lib/samples/publish.ts:47-163`。非 `completed` → 400「仅已完成（completed）教案可发布为教研样例」（73-75）。非本人/非 admin → 403（70-72）。`samplePublishOptOut` → 403（76-78）。个人偏好关闭且非 admin → 403（79-82）。已 `revoked` → **409，教师不能自行再发布**（83-85）。快照：confirmed 优先、plan/ppt/materials 可回落 draft（31-44）。`source_lesson_plan_id` 唯一（`prisma/schema.prisma` ResearchSample），再发布走 `update` 不是 `create`（134-144）。UI：`sample-publish-card.tsx:32, 106-116` 按钮 `disabled={!completed \|\| optOut \|\| !allowPublishSample}`。 |
| **修复建议** | 409 文案在 UI 展示「联系管理员恢复」。`completed` 提示旁加「须先确认材料」。撤销后若允许教师覆盖，去掉 83-85 或改为 admin-only restore 入口（已有 `reviewResearchSample` restore，`publish.ts:199-215`）。 |
| **验证方式** | `ppt_confirmed`/`materials_draft` 点发布：按钮灰，API 400。`completed` 且允许发布：201，`research_samples` 一行，`sanitizedContent` 无班级名。再 POST：200 `created=false`。admin revoke 后再 POST：409。 |

### 3.2 脱敏 `sanitize` 边角

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（命中则 422，发布失败；内容含教师短名/用户名/残留 URL 时） |
| **证据** | `sanitizeValue` + `assertNoPersonalInfo`：`publish.ts:95-104`，实现 `src/lib/samples/sanitize.ts:294-354`。班级名/教师名/学号/画像桶/URL 会替换；**替换后若 `findPersonalInfo` 仍命中则抛错 → 422**。风险点：① `buildTeacherAliases` 把 `username` 长度≥4 且非 `admin` 加入别名（`sanitize.ts:107-108`），教案正文若出现该登录名（如 `test`、`demo`）会 422。② 教师 `displayName` 两字且与知识点常用词撞车。③ `strip_personal_links:false` 时 citations 的 `https://` 会被 `findPersonalInfo` 当成「个人链接」（336-341）；UI 固定传 `true`（`sample-publish-card.tsx:63`），直打 API 传 false 会踩。④ 质量分只写入字段，**不是闸门**（`publish.ts:110, 124-125` + `quality.ts`）。 |
| **修复建议** | username 别名加最小长度/中文名限制；422 响应把 `hits` 带回 UI。发布闸不要用「全文 includes(username)」。 |
| **验证方式** | 已实证脚本：`scripts/test-s3c.ts` / `demo-s3c.ts`。补测：把教师 username 写进 `class_profile_summary` 再发布，预期今日 422。 |

---

## 4. 「去学习通五步」与导出 `activity_list`

| 字段 | 内容 |
|---|---|
| **判定** | **可行**（两者解耦，不是死路）；**有风险**（教师会以为勾选/导出互为前置） |
| **证据** | 勾选持久化：`generation_meta.chaoxing_five_steps`，`src/lib/prepare/chaoxing-steps.ts:1-4, 38-61`；API `src/app/api/lesson-plans/[id]/chaoxing-steps/route.ts:35-72`。确认/再生成用 `parseGenMeta` 整包写回（`persist.ts:53-79, bumpMetaOnConfirm 111-124`），**不会抹掉**五步。`activity_list` 渲染**不读**勾选（`question-bank.ts:253-320`），只在文末打印五步标题常量（317 行，来自 `status.ts:73-114`）。`toolkit` 才把勾选快照写成 `chaoxing_checklist`（`run.ts:441, 449`）。UI 卡片出现条件：① 任意页签顶部，仅 `status==="completed"`（`lesson-dev-panel.tsx:655-661`）；② 导出页、材料页，仅 `status==="materials_draft"`（929-935, 1004-1010）。**`ppt_confirmed` 且尚未生成材料时，界面上没有五步卡片**——此时仍可导出 `activity_list`。勾选不是导出前置，导出也不是勾选前置。 |
| **修复建议** | 卡片改为 `statusRank>=plan_confirmed` 即显示（与「对照活动清单去勾」一致）。导出页用一句话写清：「活动清单=照做文本；五步=教师核对；互不阻断。」 |
| **验证方式** | `plan_confirmed` 导出 `activity_list` 应 201。`completed` 勾选 ①② → `GET chaoxing-steps` 为 done；再导出 `toolkit`，checklist 对应行「已勾选」。全不勾导出 toolkit 仍 201。 |

---

## 5. 实验课时（现已有 objectives）与题库是否为空

### 5.1 教案 / PPT / 材料导出是否顺畅

| 字段 | 内容 |
|---|---|
| **判定** | G1：**可行**（课时目标已录入）。生成教案：**死路**（UI 硬编码案例课型，见 1.4）。绕开 G2 后教案确认/PPT/材料：**可行**，PPT 有讨论课硬约束 **有风险**。 |
| **证据** | G1 只看 `objectives.length≥1`：`src/lib/prepare/g1.ts:18-32`，确认教案/材料都走（`confirm/route.ts:67-74`）。整学期内容已声明实验课 3–5 条目标（`整学期备课-验收示范说明.md`）。实验课 `isCaseLessonType("实验")===false`（`g2.ts:14-27`），**本不应**要主案例。材料生成提示词仍写「习题与案例讨论」（`generate.ts:523-524`），实验课也会被要求出 `exercises`。PPT：若模型把 `lesson_type` 写成含「讨论/案例」（UI 又在请求里写死「案例讨论」），`schema.ts:274-277, 384-395` 强制争议页+记录页，实验课 PPT 可能 `ppt_hard_constraint` 不得确认。导出教案/材料不依赖课型。学时 2，PPT 上限 16 页（`types.ts:53`）一般够。 |
| **修复建议** | 同 1.4 去掉课型硬编码。材料 `userExtra` 对 `lesson_type` 含「实验」改为「实验步骤/记录表/思考题，exercises 至少 2 道（含 1 道 recall 以便题库）」。PPT `isDiscussionLesson` 不要被请求参数污染。 |
| **验证方式** | 实验1：`lessonType:"实验"` 生成教案 → 确认（G1 通过）→ 生成 PPT/材料 → 导出 `plan_docx`/`ppt`/`materials_student`。用 UI 默认「案例讨论」应 G2 失败（对照）。 |

### 5.2 `question_bank` 必须材料里有习题才不空

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（导出不失败，但是空题库；实验课若只有 cases/操作步骤则必空） |
| **证据** | `question-bank.ts:18-20, 75-76` 只 map `exercises`。确认材料 UI 允许「仅 cases 无 exercises」（`lesson-dev-panel.tsx:419-423`）——此时可 `completed`，题库仍空。实验课 authentic_task 无 options 会进简答题列（`question-bank.ts:57-61`），学习通仍能导入，但 recall 单选可能为 0。 |
| **修复建议** | 材料 schema：`exercises.length≥1`。实验课生成至少 1 道 `recall`。空题库导出 400 或显著 warning。 |
| **验证方式** | 导出后数 xlsx「题库」数据行；实验1 若 N=0 记失败（产品口径），今日代码会当成功。 |

---

## 6. 额外发现（会使某步不可行）

### 6.1 生成任务 persist 失败仍标 `done`

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（偶发假成功：UI 说已生成，草稿没有，确认按钮一直灰） |
| **证据** | `src/lib/ai/tasks.ts:106-128`：persist `catch` 只 `console.error`，随后仍把 task 写成 `done`。 |
| **修复建议** | persist 失败则 task=`failed`，不要 `done`。 |
| **验证方式** | 单测 mock persist throw，期望 task.status=`failed`。 |

### 6.2 确认 PPT 没有「半执行」保护（材料确认有）

| 字段 | 内容 |
|---|---|
| **判定** | **有风险** |
| **证据** | 材料确认专门拒绝 RANK < `materials_draft`，避免「写入 confirmed 但状态不前进」（`confirm/route.ts:105-114` 注释）。PPT 确认 **没有** 对等闸：`status=plan_draft` 但 draft 里还有旧 ppt 时，UI 按钮只看 `bundle.ppt` 与 schema（`lesson-dev-panel.tsx:808-813`），API 会把 ppt 写入 confirmed，`nextStatus` 因 RANK < `ppt_draft` **保持 `plan_draft`**（`state.ts:67-68`）。 |
| **修复建议** | 与材料对称：RANK < `ppt_draft` 时 400 `need_ppt_draft`。 |
| **验证方式** | `completed`→重生成教案=`plan_draft`→不确认教案直接点「确认 PPT」：今日可能改 confirmed.ppt 而 status 仍 `plan_draft`。 |

### 6.3 flagged 材料/教案不阻断确认

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（与文案「不得直接确认」不符） |
| **证据** | 生成成功文案：`lesson-dev-panel.tsx:253-256`「已生成但存在标红，不得直接确认」。确认教案只拦 G1（311-316）；确认材料只拦 G1+空材料；**都不看 flagged / 材料 schema**。后端确认材料不调用 `validateGenerationPayload("materials")`。PPT 确认则拦 schema（`confirm/route.ts:85-95`，`lesson-dev-panel.tsx:372-377`）。 |
| **修复建议** | 材料确认复用 PPT 模式：schema invalid → 400。教案确认至少把 `flagged` 当强警告（产品已用质量六条作非硬阻断，需在文案上统一）。 |
| **验证方式** | 生成材料缺 `objective_ids`（schema 429-454 行会标红）→ 任务 flagged → 今日仍可确认到 `completed`。 |

### 6.4 导出按钮与校验不同步

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（不是死路，是 UI 撒谎） |
| **证据** | `lesson-dev-panel.tsx:904-927` vs `run.ts` 各 `need_*`。 |
| **修复建议** | 按 §2.8 表禁用并 title 写 code。 |
| **验证方式** | `plan_draft` 点「教师版材料」「题库」「toolkit」应灰；今日可点，返回 400。 |

### 6.5 无「跳过材料完成备课」通道

| 字段 | 内容 |
|---|---|
| **判定** | **有风险**（讲授课若认为不需要材料，则永远不能 `completed`，也就不能发布样例） |
| **证据** | `complete_removed`：`confirm/route.ts:116-124`。`publish.ts:73-75` 只认 `completed`。 |
| **修复建议** | 若讲授课允许无材料完成：`confirm` 增加 `step=complete` 且课型非案例，或发布闸改为 `plan_confirmed && (materials 已确认 \|\| 非案例型)`。若产品坚持「完成=有材料」，保持现状并在 UI 写死「三类课时都必须生成材料」。 |
| **验证方式** | 讲授课只确认教案+PPT：发布按钮保持灰（今日如此）。 |

### 6.6 占位章 G3（当前学期内容已灌满则不是死路）

| 字段 | 内容 |
|---|---|
| **判定** | 当前 29 课时内容库：**可行**。若有人重跑旧 `seed/course.ts` 占位章：**死路** |
| **证据** | G3：`g3.ts:27-56` + `teachable.ts:9-35`（description &lt; 40 字拒生成）。`generate/route.ts:81-95` 对 plan/ppt/materials 都查。旧占位：`seed/course.ts:173-218` 知识点 description 空。当前验收说明：29 课时 G1/G3 全绿。 |
| **修复建议** | 禁止对占位章点生成；知识点页面对短 description 给同一句 G3 文案。 |
| **验证方式** | 抽一课时 `POST generate`，期望不再 `g3_short_description`。 |

---

## 《逐课时验收脚本》

环境：演示班「2024级演示班（通识）」或「2024级法学1班」；Cookie 登录 admin。课时 id **不要写死**（`seed/current-structure.json` 的 id 会随导入变化）。先解析：

```bash
# 0) 登录拿 Cookie（按项目实际 login API）
# 1) GET /api/classes → 记 CLASS_ID
# 2) GET /api/lesson-plans?classId=$CLASS_ID  （已有 plan 时带 status）
# 3) 备课页 SSR 的 wizard.lessons 或：GET /api/chapters?courseId=… 再 GET /api/lessons?chapterId=…
# 按 title 匹配下表代表课时，记下 LESSON_ID；若尚无 plan，生成后用返回/列表拿 PLAN_ID
```

代表课时（8 章各 1 + 实验 1；第 8 章第 1 课时 plan_docx/ppt 已实证，本脚本从材料段接上）：

| 代号 | 章 | 代表课时 title 含 | 课型口径 | 主案例 |
|---|---|---|---|---|
| L0 | 第0章 计算思维与计算机基础 | `计算思维（0.1–0.3）` | 讲授 | 无 |
| L1 | 第1章 人工智能概述 | `人工智能概述（1/2）` | 案例 | DeepSeek-R1 |
| L2 | 第2章 Python程序设计基础 | `Python程序设计基础（1/4）` | 讲授 | 无 |
| L5 | 第5章 计算机视觉 | `计算机视觉技术与应用（1/3）` | 案例 | 香港换脸诈骗 |
| L6 | 第6章 智能语音 | `智能语音处理与应用（1/3）` | 案例 | AI 声音人格权 |
| L7 | 第7章 NLP | `自然语言处理与应用（1/2）` | 案例 | 流浪狗舆情 |
| L8 | 第8章 生成式大模型 | `生成式大模型简介` | 案例 | 文生图著作权（已实证到 ppt/plan_docx） |
| E1 | 实验篇 | `实验1 Python程序设计基础实验` | 实验 | 无 |

**课型参数（绕开 1.4 死路）：** 讲授传 `"讲授"`，案例传 `"案例讨论"` 且 body 带已选 `caseId`，实验传 `"实验"`。对照坑：UI 默认 `"案例讨论"` 且无 caseId 时 L0/L2/E1 应 **400 `g2_no_main_case`**（记为已知死路，不挡案例课验收）。

### 公共 API 序列（每个代表课时）

设 `$C=CLASS_ID` `$L=LESSON_ID` `$T=课型` `$CASE=主案例id或省略`。

```text
# A. 教案（L8 若已 plan_confirmed / ppt_confirmed 则从 C 接）
POST /api/lesson-plans/generate
  {classId:$C, lessonId:$L, step:"plan", lessonType:$T, caseId:$CASE, run:true}
→ 201 task.id ；轮询 GET /api/tasks/:id 至 done|failed（≤90×2s，与面板 pollTask 一致）
期望：done 且 result.payload.objectives.length≥1；failed+g2_no_main_case 仅允许出现在「未传正确课型/无主案例」对照。
GET  /api/lesson-plans?classId=$C → 该课 status=plan_draft，记下 planId=$P

PUT  /api/lesson-plans/$P/confirm  {step:"plan"}
期望：200，plan.status=plan_confirmed；400 g1_no_objectives 视为该课内容库失败（当前学期不应出现）。

# B. PPT（可与 C 调换顺序）
POST /api/lesson-plans/generate {classId:$C, lessonId:$L, step:"ppt", run:true}
→ 轮询 done
PUT  /api/lesson-plans/$P/confirm {step:"ppt"}
期望：schema.valid 时 200；status 为 ppt_confirmed，或已是 materials_draft/completed 则不降级。
      schema invalid → 400 ppt_hard_constraint，不得进入「已确认 PPT」。
POST /api/lesson-plans/$P/export {type:"ppt"}     期望 201（已实证）
POST /api/lesson-plans/$P/export {type:"plan_docx"} 期望 201（已实证）

# C. 材料 → completed（本审查重点）
POST /api/lesson-plans/generate {classId:$C, lessonId:$L, step:"materials", run:true}
→ 轮询 done
期望：status=materials_draft（若已 completed 则仍 completed，见 1.3）；
      draft.materials.exercises 为数组。E1 额外断言 exercises.length≥1（否则题库空，记有风险）。
PUT  /api/lesson-plans/$P/confirm {step:"materials"}
期望：200，status=completed。
      对照：status=ppt_confirmed 且从未 generate materials → 400 need_materials_draft。
      对照：{step:"complete"} → 400 complete_removed。

# D. 其余导出（均 POST /api/lesson-plans/$P/export {type}）
type=materials_teacher     期望 201；若确认前导出，warnings 含「材料尚未确认」
type=materials_student     期望 201
type=question_bank         期望 201；下载 csv/xlsx，数据行 = exercises.length（E1 行数=0 则记缺陷）
type=question_bank_xlsx    期望 201；文件集合无 activity_list（与 question_bank 区分）
type=activity_list         期望 201（即使未勾五步）；正文含本章课时目录与「照做顺序」
type=toolkit               期望 201；含 chaoxing_checklist；无材料时应 400 need_materials（本步已有材料）

# E. 学习通五步（不阻断 D）
PUT  /api/lesson-plans/$P/chaoxing-steps {step:"align_chapter", done:true}
… 五步均可
GET  同上，期望 done/at
再 export toolkit，checklist 对应行「已勾选」
对照：五步全 false 时 activity_list 仍 201

# F. 发布样例
POST /api/lesson-plans/$P/publish-sample {strip_personal_links:true}
期望：201/200，sample.status=published；sanitizedContent 无真实班级名/教师名。
对照：在 materials_draft 时调用 → 400。
对照：opt-out 后 → 403。
```

### 分课时期望状态

| 课时 | 应用课型 $T | 生成教案 | 确认教案 | PPT 确认 | 材料确认 | 导出题库 | 发布样例 |
|---|---|---|---|---|---|---|---|
| L0 讲授 | `讲授` | `plan_draft` | `plan_confirmed` | 可 | `completed` | 行数≥1 为佳 | 201 |
| L1 案例 | `案例讨论`+case | 同上 | 同上 | 讨论课须有争议/记录页 | 同上 | 习题+案例都有 | 201 |
| L2 讲授 | `讲授` | 同上 | 同上 | 可无争议页 | 同上 | 同 L0 | 201 |
| L5 案例 | `案例讨论`+case | 同上 | 同上 | 同 L1 | 同上 | 同上 | 201 |
| L6 案例 | `案例讨论`+case | 同上 | 同上 | 同 L1 | 同上 | 同上 | 201 |
| L7 案例 | `案例讨论`+case | 同上 | 同上 | 同 L1 | 同上 | 同上 | 201 |
| L8 案例 | 已实证到 ppt/plan_docx | 跳到 C | 已 `plan_confirmed` | 已 `ppt_confirmed` | **本步必须新跑** | 新跑 | 新跑 |
| E1 实验 | `实验` | 绕开 G2 后 `plan_draft` | G1 应过 | 勿被「案例讨论」schema 误杀 | `completed` | **断言习题非空** | 201 |

UI 对照（每课至少抽 1 次，建议 L0 + L1 + E1）：

1. 材料页「确认材料（完成备课）」仅在 `materials_draft` 且有 `bundle.materials` 时可点。  
2. `completed` 后页顶出现五步卡片；`ppt_confirmed` 无材料时不应依赖五步才能导出活动清单。  
3. 发布按钮仅 `completed` 可点。  
4. 讲授课/实验课未选主案例时，用 **UI 默认生成** 应失败（死路 1.4）；用正确 `$T` 的 API 应成功。

---

## 修复优先级（建议）

1. **P0 死路**：去掉 `lessonType: "案例讨论"` 硬编码（面板 240 行 + generate route 71 行），否则无主案例讲授/实验永远到不了材料段。  
2. **P0 完成态**：确认材料的状态准入不要死盯 `=== materials_draft`（重生成教案后假死）。  
3. **P1**：空 `exercises` 不得静默导出题库；材料确认前后端空材料规则对齐。  
4. **P1**：persist 失败不要标 `done`；PPT 确认补半执行闸。  
5. **P2**：导出按钮按 `need_*` 禁用；五步卡片在 `plan_confirmed` 即显示；发布 422/409 把原因打到面板。

---

材料确认主路径代码是通的，真正会卡死「备课→上课」的是 UI/G2 把所有课当成案例课、以及确认按钮死盯 `materials_draft`。
题库/toolkit 缺材料会 400，缺习题却会空文件成功；activity_list 与五步勾选互不阻断。
实验课已有 objectives，G1 不再是死路，但必须显式传课型 `实验` 并保证材料里有 exercises，否则完成备课后题库仍空、样例也发不出去。
