Kaynağa Gözat

Expand product-service curriculum with course home, diagrams and self-assessment

Unify lessons and the FastAPI lab around the product-service example, add distributed systems lessons and module reviews, and refresh downloadable materials and validation scripts.
Codex 1 ay önce
ebeveyn
işleme
53d27757de
50 değiştirilmiş dosya ile 20584 ekleme ve 6668 silme
  1. 45 19
      README.md
  2. 806 0
      app/course-home.css
  3. 752 0
      app/course-home.tsx
  4. 2 1
      app/course-navigation.ts
  5. 414 81
      app/course.tsx
  6. 7 7
      app/demos.tsx
  7. 1529 0
      app/diagram-catalog.ts
  8. 126 0
      app/globals.css
  9. 1 1
      app/layout.tsx
  10. 39 1
      app/lesson-types.ts
  11. 1945 1606
      app/lessons.json
  12. 72 15
      app/module-catalog.ts
  13. 407 0
      app/review-page.tsx
  14. 82 0
      app/review-state.ts
  15. 225 0
      app/review.css
  16. 2 2
      app/task-delivery-demo.tsx
  17. 270 0
      app/teaching-diagrams.css
  18. 374 0
      app/teaching-diagrams.tsx
  19. 34 32
      examples/README.md
  20. 29 25
      examples/VERIFICATION.md
  21. 52 49
      examples/main.py
  22. 80 51
      examples/test_main.py
  23. 27 19
      examples/web-client.html
  24. 310 1392
      public/content/api-database.json
  25. 456 237
      public/content/database.json
  26. 1404 0
      public/content/distributed.json
  27. 362 178
      public/content/docker.json
  28. 357 185
      public/content/linux.json
  29. 0 145
      public/content/redis.json
  30. 1896 0
      public/content/review.json
  31. 0 1084
      public/content/tasks.json
  32. 329 156
      public/content/troubleshooting.json
  33. 428 189
      public/downloads/api-database-lessons.md
  34. BIN
      public/downloads/api-lab.zip
  35. 508 176
      public/downloads/api-lessons.md
  36. 644 222
      public/downloads/database-lessons.md
  37. 1923 0
      public/downloads/distributed-lessons.md
  38. 437 144
      public/downloads/docker-lessons.md
  39. 421 168
      public/downloads/linux-lessons.md
  40. 416 192
      public/downloads/redis-lessons.md
  41. 1891 0
      public/downloads/review-lessons.md
  42. 386 131
      public/downloads/tasks-lessons.md
  43. 421 133
      public/downloads/troubleshooting-lessons.md
  44. 88 24
      scripts/build-course-content.mjs
  45. 134 0
      scripts/check-course-diagrams.mjs
  46. 3 0
      scripts/check-python-snippets.py
  47. 166 0
      scripts/check-review.mjs
  48. 131 0
      scripts/check-self-study.mjs
  49. 135 0
      scripts/review-content.mjs
  50. 18 3
      scripts/test-course-navigation.mjs

+ 45 - 19
README.md

@@ -1,19 +1,31 @@
 # 术核 AI · 后端工程课件
 
-面向掌握 Python 基础、需要补齐后端工程经验的开发者。课程按 API、数据库、持久化服务、任务、缓存、容器、部署和排障组织。
+面向掌握 Python 基础、需要补齐后端工程经验的开发者。用轻量电商商品服务 `shop-service` 贯穿 API、数据库、持久化服务、任务、缓存、容器、部署、排障与分布式系统设计。共 10 个模块:前九个技术模块含 110 节教学课,末尾复盘模块含 9 个自测单元,共 119 项。首页保留服务端 Agent 参考架构,说明这些后端能力的扩展用途。
 
-| 模块          | 标识            | 课时 |
-| ------------- | --------------- | ---- |
-| API 服务开发  | api             | 16   |
-| 数据库开发    | database        | 12   |
-| API + 数据库  | api-database    | 12   |
-| 异步任务开发  | tasks           | 16   |
-| Redis 缓存    | redis           | 10   |
-| Docker 容器化 | docker          | 10   |
-| Linux 部署    | linux           | 10   |
-| 线上问题排查  | troubleshooting | 12   |
+| 模块           | 标识            | 课时         |
+| -------------- | --------------- | ------------ |
+| API 服务开发   | api             | 16           |
+| 数据库开发     | database        | 12           |
+| API + 数据库   | api-database    | 12           |
+| 异步任务开发   | tasks           | 16           |
+| Redis 缓存     | redis           | 10           |
+| Docker 容器化  | docker          | 10           |
+| Linux 部署     | linux           | 10           |
+| 线上问题排查   | troubleshooting | 12           |
+| 分布式系统设计 | distributed     | 12           |
+| 复盘与自测     | review          | 9 个自测单元 |
 
-每节包含目标、概念、实战步骤、验收、排错、练习答案和官方文档。后七个模块另有生产场景、实现决策、故障演练和恢复验收。
+前九个模块的教学课按自学顺序组织:具体问题与起点 → 可见目标 → 逐步操作或推演 → 核对结果 → 图解串联 → 进一步验证与练习 → 核心概念回顾。每一步都分别交代做什么、应该看到什么、刚才为什么会这样;首次出现的术语结合当步操作解释。文字课件可独立阅读,视频作为补充。数据库至排障七个模块另有生产场景、实现决策、故障演练和恢复验收。各模块概览与关键课时共配有 38 幅 SVG 图解,包含文字读图步骤和易错提示;首页另有一幅完整 Agent 参考架构图。
+
+业务限定为商品管理、分类、评价和商品清单导出。商品接口统一 `/products`,核心字段为 `name`、`description`、`price_cents` 和 `is_active`;金额用整数分表示,默认 0 表示免费教学样品,上架状态默认 `false`。订单、支付、库存和促销不纳入实战,保证每次增加内容都用于解释技术。
+
+数据库第 02 课从需求推导商品表,讲清实体、字段字典、类型、主键、非空、默认值、检查约束和 UTC 时间;第 04 课设计 `categories` 与 `product_categories`,讲清关系基数、联合主键、外键和删除策略。新增商品关系图,并让后续索引、迁移与恢复练习沿用同一模型。保持原有课时编号及链接。
+
+分布式系统设计是纯理论模块:场景推演、理解自检、常见误区、面试问答与设计练习,不包含环境安装、实战代码或故障注入操作。涵盖架构模式、高可用、复制分片、一致性、缓存、消息、事务、幂等、分布式锁、容错、容量与容灾。
+
+复盘与自测位于课程末尾,九个单元分别对应九个技术模块,每单元含六个理解问题、两个情境推演及四项掌握标准,共 72 个问题与 36 项证据要求。网页默认收起参考要点,学员先独立回答,再核对误区并沿链接回学。八项问题均自评可独立完成、四项证据均核对后,才显示本轮自评达标;网页不自动判断文字答案。回答和自评只保存在当前浏览器,题目实质更新后保留旧笔记并要求重新自评。下载版将参考要点放在每个单元末尾,适合离线复盘。
+
+首页架构展示后端底座在完整 Agent 服务中的位置;当前可运行参考代码仍是 `shop-service` 基础项目。Agent 编排、模型与外部工具用于说明目标场景,不代表仓库已实现完整 Agent 运行系统。
 
 ## 本地运行
 
@@ -24,26 +36,34 @@ npm install
 npm run dev
 ```
 
-访问终端显示的地址。原有 `?lesson=01` 链接继续指向 API 课时。其他模块使用 `?module=database&lesson=01`,模块概览使用 `?module=database`。浏览器前进、后退和移动端目录均可用。
+访问终端显示的地址进入课程总览。原有 `?lesson=01` 链接继续指向 API 课时,API 概览使用 `?module=api`。其他模块使用 `?module=database&lesson=01`,模块概览使用 `?module=database`,理论课使用 `?module=distributed&lesson=01`。浏览器前进、后退和移动端目录均可用。
 
 ## 内容维护
 
 - `app/lessons.json`:API 模块数据。
-- `public/content/{模块标识}.json`:后续七个模块。按需载入,切换模块时显示载入或重试状态。
+- `public/content/{模块标识}.json`:后续八个模块。按需载入,切换模块时显示载入或重试状态。
 - `app/module-catalog.ts`:名称、前置关系、工程边界、完成标准和课时数量。
 - `app/course.tsx`:通用课件页面,不需要为每个新模块复制页面代码。
-- `app/lesson-types.ts`:课件结构。每个模块内部课时从 `01` 连续编号;不保存教师视图或录制时长字段。
+- `app/course-home.tsx`、`app/course-home.css`:课程总览、Agent 参考架构、长任务流程与模块定位。
+- `app/diagram-catalog.ts`、`app/teaching-diagrams.tsx`、`app/teaching-diagrams.css`:教学图数据、课时映射与可访问 SVG 渲染。修改映射时检查对应模块及课时编号。
+- `app/lesson-types.ts`:教学与复盘的联合类型;以下字段约束用于 `TeachingLesson`。`intro` 必须包含场景、起点和可见终点;每个步骤的 `body`、`expected`、`explanation` 分别描述操作、现象和解释,`concepts` 用于课末回顾。每个模块内部课时从 `01` 连续编号;不保存教师视图或录制时长字段。
+- `app/review-page.tsx`、`app/review-state.ts`、`app/review.css`:复盘页面及本地自评记录。`ReviewLesson` 与教学课件分开建模,包含原模块、理解问题、情境题、参考要点、回学课时和掌握证据。
 - `app/demos.tsx`、`app/task-delivery-demo.tsx`:浏览器内概念演示。模型演示不发送实际后端请求。
 - `app/globals.css`:正文、导航和响应式样式。
 - `public/brand/`:从 shuh.ai 下载的原始 Logo、favicon 和来源记录。
-- `scripts/build-course-content.mjs`:验证课件结构、课时数、生产练习字段及文档链接格式,生成各模块 Markdown 下载。
+- `scripts/build-course-content.mjs`:验证课件结构、课时数、自学起点、逐步观察与解释、生产练习字段及文档链接格式,全部验证通过后生成各模块 Markdown 下载;理论课另外验证面试问答,并禁止代码和生产演练字段。
+- `scripts/check-self-study.mjs`:检查前九个技术模块的下载课件是否保持问题在前、操作先于现象和解释、核心概念在最后的阅读顺序,并逐段核对内容没有在导出时丢失。
+- `scripts/review-content.mjs`、`scripts/check-review.mjs`:校验九模块覆盖、问题与证据数量、回学链接和原标题,生成复盘讲义,并检查参考答案后置、自评门槛、本地恢复、模块隔离及损坏记录处理。
 
 增加课时后同步修改目录数量;分组从每节 `group` 字段自动生成。修改内容后运行:
 
 ```bash
 npm run content
+node scripts/check-self-study.mjs
+node --experimental-strip-types scripts/check-review.mjs
 python scripts/check-python-snippets.py
 node --experimental-strip-types scripts/test-course-navigation.mjs
+node --experimental-strip-types scripts/check-course-diagrams.mjs
 npm exec tsc -- --noEmit
 npm run build
 ```
@@ -67,13 +87,19 @@ npm run build
 
 ## 实战代码与验证边界
 
-`examples/` 和 `public/downloads/api-lab.zip` 是已运行验证的 FastAPI 基础参考项目,包含 30 项测试。其接口不自动包含后续课程新增的 ORM、队列和部署配置。
+`examples/` 和 `public/downloads/api-lab.zip` 是已运行验证的 FastAPI 商品服务基础项目,包含 41 项测试。其接口不自动包含后续课程新增的 ORM、队列和部署配置。本次运行环境与此前 `requirements.txt` 锁定环境存在版本差异,实际版本和真实网络验收见 `examples/VERIFICATION.md`。
+
+后续模块在课件中按步骤创建和演进 `shop-service`,提供 SQL、Python、Compose、systemd 和 Nginx 等示例。模块 Markdown 可从网页下载。代码片段必须遵循每一步的文件路径、前置定义和替换说明,不能全部无差别追加到一个文件。外部服务实验需要学员具备对应环境。
+
+自学编排校验覆盖全部 110 节课的准备说明、每步操作/观察/解释及末尾回顾,并逐段核对下载讲义。API 第 01 课的健康检查、文档入口和修改返回值使用 TestClient 验证;第 16 课前四步的测试逐段运行,累计结果为 1、2、4、6 项通过。浏览器抽查覆盖九个模块,确认步骤展示、图解位置和原有概念锚点可用。这些检查不代替全部外部服务实验的运行验证。
+
+本次电商改版在隔离的 MySQL 8.4.11 容器实测了商品/分类/关联表 DDL、默认值、非法价格、时间与版本更新、唯一约束、外键、RESTRICT/CASCADE 和组合索引。缓存课件使用内存适配器验证商品字段往返、非法金额和坏缓存恢复;任务第 08/09 课直接调用导出函数,验证含价格的商品快照和重复执行。本次未重跑完整 ORM 迁移、Celery 拓扑、Ubuntu/systemd 发布或全部故障演练;此前底层 MySQL 事务隔离、并发锁、任务 outbox、排障 SQL 与备份恢复记录不等于本次改版全链路实测。Python 3.11 语法检查与实际运行验证分别统计。
 
-后续模块在课件中按步骤创建和演进 `notes-service`,提供 SQL、Python、Compose、systemd 和 Nginx 等示例。模块 Markdown 可从网页下载。代码片段必须遵循每一步的文件路径、前置定义和替换说明,不能全部无差别追加到一个文件。外部服务实验需要学员具备对应环境。MySQL 改版使用隔离的 MySQL 8.4.11 容器验证了约束、事务隔离、并发锁、任务状态与 outbox 恢复、排障 SQL,以及 mysqldump 备份与空库恢复。任务验证中的消息发送使用模拟;完整 Celery 拓扑、Ubuntu/systemd 发布和真实生产故障演练未执行。课件的 Python 3.11 语法检查与实际运行验证分别统计。
+内容校验、课时导航、38 幅图解校验、TypeScript 检查和生产构建通过。本次涉及的 `app/` lint 通过;全库 lint 仍报告 19 处未修改的基础组件及 hook 问题。
 
 数据库统一使用 MySQL 8.4 LTS、InnoDB 和 utf8mb4。API 使用 SQLAlchemy 2 + asyncmy;同步数据库、缓存和任务示例使用 PyMySQL。时间统一按 UTC 写入 `DATETIME(6)`,课程区分 MySQL 默认 REPEATABLE READ 与应用显式 READ COMMITTED,并按 MySQL 处理自增主键、JSON、行锁和 DDL 隐式提交。
 
-数据库使用独立 `notes_lab`,Alembic 迁移实验使用 `notes_lab_migration`,示例宿主端口为 MySQL 3307、Redis 缓存 6380、任务 broker 6384;Redis 容量、AOF、复制实验分别使用 6381、6382、6383。容器内部使用服务名和 3306/6379,不能把宿主地址机械复制到容器里。数据库备份、停止进程、锁等待、资源限制等故障操作均限于课件指定的隔离环境。
+数据库使用独立 `shop_lab`,Alembic 迁移实验使用 `shop_lab_migration`,示例宿主端口为 MySQL 3307、Redis 缓存 6380、任务 broker 6384;Redis 容量、AOF、复制实验分别使用 6381、6382、6383。容器内部使用服务名和 3306/6379,不能把宿主地址机械复制到容器里。数据库备份、停止进程、锁等待、资源限制等故障操作均限于课件指定的隔离环境。
 
 ## 发布
 

+ 806 - 0
app/course-home.css

@@ -0,0 +1,806 @@
+.course-home {
+  width: 100%;
+  min-width: 0;
+  margin: 0;
+  padding: 0;
+}
+.course-home section {
+  margin: 58px 0 0;
+}
+.home-hero {
+  max-width: 900px;
+}
+.home-hero h1 {
+  font-size: clamp(31px, 3.8vw, 46px);
+  line-height: 1.4;
+}
+.home-lead {
+  max-width: 790px;
+  color: #59677e;
+  font-size: 17px;
+  line-height: 1.95;
+}
+.home-actions {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 15px 24px;
+  margin-top: 25px;
+}
+.home-primary-link,
+.home-secondary-link {
+  display: inline-flex;
+  gap: 12px;
+  align-items: center;
+  font-size: 14px;
+}
+.home-primary-link {
+  background: var(--primary);
+  color: #fff;
+  border-radius: 7px;
+  padding: 11px 16px;
+  font-weight: 500;
+}
+.home-primary-link:hover {
+  background: #225bdc;
+}
+.home-secondary-link {
+  color: #445c80;
+}
+.home-secondary-link:hover {
+  color: var(--primary);
+}
+.home-course-facts {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 22px 54px;
+  margin: 32px 0 0;
+}
+.home-course-facts > div {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+}
+.home-course-facts dt {
+  font-size: 25px;
+  line-height: 1.3;
+  color: #243d65;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+.home-course-facts dd {
+  margin: 0;
+  font-size: 12px;
+  color: #6a768c;
+}
+.home-scope-note {
+  display: flex;
+  align-items: flex-start;
+  gap: 14px;
+  padding: 21px 24px;
+  margin-top: 32px;
+  border: 1px solid #dfe8f7;
+  border-left: 3px solid #80a9ef;
+  border-radius: 0 8px 8px 0;
+  background: #f7faff;
+}
+.home-scope-note > svg {
+  margin-top: 3px;
+  color: #4b74b7;
+}
+.home-scope-note strong {
+  display: block;
+  color: #324c74;
+  font-size: 14px;
+}
+.home-scope-note p {
+  font-size: 13px;
+  line-height: 1.85;
+  color: #65738a;
+  margin: 5px 0 0;
+}
+.home-section-heading {
+  margin-bottom: 23px;
+}
+.home-section-heading > span {
+  display: block;
+  font-family: var(--font-geist-mono), monospace;
+  font-size: 11px;
+  letter-spacing: 1.3px;
+  color: #70809a;
+  margin-bottom: 10px;
+}
+.home-section-heading p {
+  max-width: 850px;
+  color: #647188;
+  font-size: 14px;
+  margin: 11px 0 0;
+  line-height: 1.85;
+}
+.home-architecture-figure {
+  container: home-architecture / inline-size;
+  margin: 0;
+  border: 1px solid #dfe6f0;
+  border-radius: 11px;
+  background: #fcfdff;
+  overflow: hidden;
+}
+.home-diagram-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 24px;
+  padding: 19px 22px;
+  border-bottom: 1px solid #e4eaf3;
+  font-size: 0.8125rem;
+  color: #576983;
+}
+.home-diagram-legend > span {
+  display: inline-flex;
+  align-items: center;
+  gap: 9px;
+}
+.home-diagram-legend i {
+  display: inline-block;
+  flex-shrink: 0;
+  width: 15px;
+  height: 13px;
+  border-radius: 3px;
+}
+.home-legend-module {
+  background: #f3f7ff;
+  border: 1px solid #a4bee6;
+}
+.home-diagram-legend .home-legend-task {
+  position: relative;
+  width: 25px;
+  height: 0;
+  border-top: 2px solid #b87020;
+  border-radius: 0;
+}
+.home-legend-task::after {
+  content: '';
+  position: absolute;
+  right: 0;
+  top: -4px;
+  width: 6px;
+  height: 6px;
+  border-top: 2px solid #b87020;
+  border-right: 2px solid #b87020;
+  transform: rotate(45deg);
+}
+.home-legend-context {
+  background: #f2f4f8;
+  border: 1px dashed #a8b2c2;
+}
+.home-layered-architecture {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 180px;
+  gap: 16px;
+  padding: 22px;
+}
+.home-architecture-stack {
+  display: grid;
+  gap: 12px;
+  min-width: 0;
+}
+.home-architecture-layer {
+  display: grid;
+  grid-template-columns: 116px minmax(0, 1fr);
+  border: 1px solid #dbe5f3;
+  border-left: 3px solid #83a6d8;
+  border-radius: 7px;
+  background: #f5f8fd;
+  min-width: 0;
+}
+.home-layer-heading {
+  padding: 18px 12px;
+  align-self: center;
+}
+.home-layer-heading h3 {
+  margin: 0;
+  color: #304c74;
+  font-size: 0.9375rem;
+  font-weight: 600;
+}
+.home-layer-heading p {
+  margin: 6px 0 0;
+  color: #687a92;
+  font-size: 0.75rem;
+  line-height: 1.7;
+}
+.home-layer-content {
+  min-width: 0;
+  padding: 12px;
+  border-left: 1px solid #e0e8f4;
+  background: #fff;
+  border-radius: 0 6px 6px 0;
+}
+.home-layer-nodes {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+}
+.home-layer-client .home-layer-nodes,
+.home-layer-access .home-layer-nodes {
+  grid-template-columns: minmax(0, 1fr);
+}
+.home-layer-runtime .home-layer-nodes {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.home-layer-tasks {
+  border-color: #e7d7bf;
+  border-left-color: #c89855;
+  background: #fbf7f0;
+}
+.home-layer-tasks .home-layer-content {
+  border-left-color: #efe5d6;
+}
+.home-layer-tasks .home-layer-nodes {
+  grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr) 18px minmax(0, 1fr);
+  gap: 5px;
+}
+.home-task-arrow {
+  color: #b87020;
+  align-self: center;
+}
+.home-layer-data {
+  border-left-color: #78a4ae;
+  background: #f2f8fa;
+}
+.home-layer-runtime {
+  border-left-color: #899bb3;
+  background: #edf1f7;
+}
+.home-architecture-node {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  padding: 13px 12px;
+  background: #f3f7ff;
+  border: 1px solid #b9cdeb;
+  border-radius: 5px;
+  overflow-wrap: anywhere;
+}
+.home-architecture-node strong {
+  display: block;
+  margin-bottom: 5px;
+  color: #253f66;
+  font-size: 0.875rem;
+  line-height: 1.65;
+  font-weight: 600;
+}
+.home-architecture-node > span {
+  color: #566781;
+  font-size: 0.875rem;
+  line-height: 1.8;
+}
+.home-node-context {
+  background: #f4f5f8;
+  border-color: #bdc5d2;
+  border-style: dashed;
+}
+.home-node-queue {
+  background: #fff8ee;
+  border-color: #e2cba9;
+}
+.home-node-storage {
+  background: #f0f7fa;
+  border-color: #b6ced8;
+}
+.home-node-runtime {
+  background: #f1f4f9;
+  border-color: #c6d2e2;
+}
+.home-architecture-node:is(a):hover,
+.home-architecture-node:is(a):focus-visible {
+  border-color: #2f6bff;
+  background: #edf4ff;
+}
+.home-layer-note {
+  margin: 9px 1px 0;
+  font-size: 0.75rem;
+  line-height: 1.85;
+  color: #65748b;
+}
+.home-crosscutting {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-width: 0;
+  padding: 16px 12px 12px;
+  background: #eff4fc;
+  border: 1px dashed #a9bddb;
+  border-radius: 7px;
+}
+.home-crosscutting-heading > span {
+  color: #355783;
+  font-size: 0.9375rem;
+  font-weight: 600;
+}
+.home-crosscutting-heading p {
+  margin: 4px 0 0;
+  color: #657996;
+  font-size: 0.75rem;
+  line-height: 1.8;
+}
+.home-crosscutting-card {
+  flex: 1;
+  padding: 15px 12px;
+  border: 1px solid #ccdaee;
+  border-radius: 5px;
+  background: #fff;
+  overflow-wrap: anywhere;
+}
+.home-crosscutting-number {
+  display: block;
+  margin-bottom: 10px;
+  color: #527bb4;
+  font-family: var(--font-geist-mono), monospace;
+  font-size: 0.8125rem;
+}
+.home-crosscutting-card h3 {
+  margin: 0;
+  color: #304c74;
+  font-size: 0.9375rem;
+}
+.home-crosscutting-card p {
+  margin: 9px 0 16px;
+  color: #62738c;
+  font-size: 0.8125rem;
+  line-height: 1.85;
+}
+.home-crosscutting-card dl {
+  display: grid;
+  gap: 12px;
+  margin: 0;
+}
+.home-crosscutting-card dt {
+  color: #3f5e88;
+  font-size: 0.8125rem;
+  font-weight: 500;
+}
+.home-crosscutting-card dd {
+  margin: 0;
+  color: #6d7d94;
+  font-size: 0.75rem;
+}
+.home-crosscutting-card ul {
+  display: grid;
+  gap: 10px;
+  margin: 0;
+  padding-left: 17px;
+  color: #526c90;
+  font-size: 0.8125rem;
+}
+.home-crosscutting-tag {
+  display: block;
+  margin-top: 18px;
+  color: #75829a;
+  font-size: 0.75rem;
+}
+.home-crosscutting-card:hover {
+  border-color: #2f6bff;
+}
+.home-architecture-figure figcaption {
+  border-top: 1px solid #e4eaf3;
+  padding: 13px 20px;
+  margin: 0;
+  color: #6b7790;
+  font-size: 12px;
+  background: #f8faff;
+  line-height: 1.9;
+}
+.home-reading-paths {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 21px;
+  margin-top: 25px;
+}
+.home-reading-paths article {
+  border-top: 2px solid #d9e5f7;
+  padding-top: 14px;
+}
+.home-reading-number {
+  display: block;
+  color: #6384b5;
+  font-size: 12px;
+  font-weight: 600;
+  margin-bottom: 5px;
+}
+.home-reading-paths h3 {
+  font-size: 15px;
+}
+.home-reading-paths p {
+  color: #65728a;
+  font-size: 13px;
+  line-height: 1.9;
+  margin: 7px 0 0;
+}
+.home-term-note {
+  padding: 18px 21px;
+  background: #f5f7fb;
+  border-radius: 7px;
+  color: #66748a;
+  font-size: 13px;
+  margin: 24px 0 0;
+  line-height: 1.9;
+}
+.home-term-note strong {
+  color: #354f73;
+}
+.home-module-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 21px;
+}
+.home-module-card {
+  display: flex;
+  flex-direction: column;
+  border: 1px solid #e0e7f1;
+  border-radius: 9px;
+  padding: 23px 24px 0;
+  scroll-margin-top: 90px;
+  min-width: 0;
+}
+.home-module-card:target {
+  border-color: #91b4ed;
+  box-shadow: 0 0 0 3px #edf4ff;
+}
+.home-module-card-top {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: #78859a;
+  font-size: 11px;
+  margin-bottom: 14px;
+}
+.home-module-card-top > svg {
+  margin-left: auto;
+  color: #6c88b0;
+}
+.home-module-number {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: #edf4ff;
+  color: #326dd0;
+  width: 31px;
+  height: 28px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 600;
+  font-family: var(--font-geist-mono), monospace;
+}
+.home-module-card h3 {
+  color: #263c5d;
+  font-size: 20px;
+}
+.home-module-question {
+  font-size: 13px;
+  color: #577399;
+  line-height: 1.85;
+  margin: 5px 0 0;
+}
+.home-module-card dl {
+  display: grid;
+  gap: 12px;
+  margin: 18px 0 23px;
+}
+.home-module-card dl > div {
+  display: grid;
+  grid-template-columns: 58px minmax(0, 1fr);
+  gap: 12px;
+}
+.home-module-card dt {
+  font-size: 12px;
+  color: #465a78;
+  font-weight: 500;
+  padding-top: 1px;
+}
+.home-module-card dd {
+  font-size: 13px;
+  color: #69758b;
+  margin: 0;
+  line-height: 1.85;
+  overflow-wrap: anywhere;
+}
+.home-module-entry {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  justify-content: space-between;
+  border-top: 1px solid #e8edf5;
+  padding: 15px 0;
+  margin-top: auto;
+  font-size: 13px;
+  color: #356cbd;
+}
+.home-module-entry:hover {
+  color: #174d9c;
+}
+.home-task-timeline {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+.home-task-timeline > li {
+  display: grid;
+  grid-template-columns: 36px minmax(0, 1fr);
+  gap: 20px;
+  position: relative;
+  padding-bottom: 23px;
+}
+.home-task-timeline > li:not(:last-child)::before {
+  content: '';
+  position: absolute;
+  left: 17px;
+  top: 34px;
+  bottom: 0;
+  width: 1px;
+  background: #d5e1f2;
+}
+.home-timeline-index {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #eff5ff;
+  border: 1px solid #d6e5fc;
+  color: #4375bc;
+  height: 35px;
+  width: 35px;
+  border-radius: 50%;
+  font-size: 13px;
+  position: relative;
+}
+.home-timeline-title {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 7px 15px;
+  align-items: center;
+  min-height: 35px;
+  margin-bottom: 5px;
+}
+.home-timeline-title h3 {
+  margin: 0;
+  font-size: 16px;
+}
+.home-timeline-title > span {
+  color: #7c715d;
+  background: #faf5ec;
+  font-size: 11px;
+  border-radius: 3px;
+  padding: 1px 7px;
+}
+.home-task-timeline p {
+  margin: 0;
+  color: #66748b;
+  font-size: 14px;
+  line-height: 1.9;
+}
+.home-failure-note {
+  padding: 21px 24px;
+  background: #f7f9fc;
+  border: 1px solid #e2e8f1;
+  border-radius: 8px;
+  margin-top: 3px;
+}
+.home-failure-note strong {
+  color: #3e5576;
+  font-size: 15px;
+}
+.home-failure-note p {
+  color: #65738b;
+  font-size: 13px;
+  margin: 7px 0 12px;
+}
+.home-failure-note a {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  color: #3a70be;
+}
+.home-learning-route {
+  padding: 0;
+  margin: 0;
+  list-style: none;
+}
+.home-learning-route > li {
+  display: flex;
+  align-items: flex-start;
+  gap: 18px;
+  padding: 23px 0;
+  border-top: 1px solid #e4eaf3;
+}
+.home-learning-route > li > svg {
+  color: #6483af;
+  margin-top: 4px;
+}
+.home-learning-route > li > div {
+  flex: 1;
+  min-width: 0;
+}
+.home-learning-route h3 {
+  font-size: 16px;
+}
+.home-learning-route p {
+  color: #68758b;
+  font-size: 13px;
+  line-height: 1.85;
+  margin: 6px 0 0;
+}
+.home-learning-route a {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  color: #4b74ac;
+  font-size: 13px;
+  white-space: nowrap;
+  margin-top: 3px;
+}
+@container home-architecture (max-width: 940px) {
+  .home-layered-architecture {
+    grid-template-columns: minmax(0, 1fr);
+    padding: 16px;
+  }
+  .home-crosscutting {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    padding: 16px;
+  }
+  .home-crosscutting-heading {
+    grid-column: 1 / -1;
+  }
+  .home-crosscutting-card {
+    padding: 16px;
+  }
+  .home-crosscutting-card dl {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 8px;
+  }
+}
+@media (max-width: 1120px) {
+  .home-reading-paths {
+    gap: 17px;
+  }
+  .home-module-card {
+    padding: 20px 20px 0;
+  }
+  .home-module-card dl > div {
+    grid-template-columns: 1fr;
+    gap: 2px;
+  }
+}
+@container home-architecture (max-width: 680px) {
+  .home-architecture-layer {
+    grid-template-columns: minmax(0, 1fr);
+  }
+  .home-layer-heading {
+    display: flex;
+    align-items: baseline;
+    flex-wrap: wrap;
+    gap: 4px 14px;
+    padding: 12px 14px;
+  }
+  .home-layer-heading p {
+    margin: 0;
+  }
+  .home-layer-content,
+  .home-layer-tasks .home-layer-content {
+    border-left: 0;
+    border-top: 1px solid #e0e8f4;
+    border-radius: 0 0 6px 6px;
+  }
+}
+@media (max-width: 800px) {
+  .home-reading-paths {
+    grid-template-columns: 1fr;
+    gap: 18px;
+  }
+  .home-reading-paths article {
+    display: grid;
+    grid-template-columns: 22px minmax(0, 1fr);
+    column-gap: 11px;
+  }
+  .home-reading-number {
+    grid-row: span 2;
+    margin-top: 2px;
+  }
+  .home-reading-paths p {
+    margin-top: 1px;
+  }
+  .home-module-grid {
+    grid-template-columns: 1fr;
+  }
+  .home-module-card dl > div {
+    grid-template-columns: 58px minmax(0, 1fr);
+    gap: 12px;
+  }
+}
+@container home-architecture (max-width: 540px) {
+  .home-layered-architecture {
+    padding: 12px;
+    gap: 12px;
+  }
+  .home-diagram-legend {
+    padding: 15px;
+    gap: 9px 16px;
+  }
+  .home-layer-nodes,
+  .home-layer-runtime .home-layer-nodes,
+  .home-layer-tasks .home-layer-nodes,
+  .home-crosscutting {
+    grid-template-columns: minmax(0, 1fr);
+  }
+  .home-task-arrow {
+    justify-self: center;
+    transform: rotate(90deg);
+  }
+  .home-layer-tasks .home-layer-nodes {
+    gap: 7px;
+  }
+  .home-architecture-node {
+    padding: 13px 14px;
+  }
+}
+@media (max-width: 540px) {
+  .course-home section {
+    margin-top: 43px;
+  }
+  .home-hero h1 {
+    font-size: 30px;
+    line-height: 1.45;
+    margin-top: 15px;
+  }
+  .home-lead {
+    font-size: 15px;
+  }
+  .home-course-facts {
+    column-gap: 21px;
+    row-gap: 15px;
+  }
+  .home-course-facts > div {
+    flex-direction: column;
+    gap: 4px;
+  }
+  .home-course-facts dt {
+    font-size: 22px;
+  }
+  .home-course-facts dd {
+    font-size: 11px;
+  }
+  .home-scope-note {
+    padding: 17px 15px;
+    gap: 10px;
+  }
+  .home-scope-note > svg {
+    width: 17px;
+  }
+  .home-section-heading h2 {
+    font-size: 22px;
+  }
+  .home-architecture-figure figcaption {
+    padding: 12px 14px;
+  }
+  .home-module-card {
+    padding: 20px 18px 0;
+  }
+  .home-module-card dl > div {
+    grid-template-columns: 1fr;
+    gap: 2px;
+  }
+  .home-task-timeline > li {
+    gap: 13px;
+  }
+  .home-failure-note {
+    padding: 19px 18px;
+  }
+  .home-learning-route > li {
+    gap: 11px;
+    flex-wrap: wrap;
+  }
+  .home-learning-route a {
+    width: 100%;
+    margin-left: 31px;
+  }
+}

+ 752 - 0
app/course-home.tsx

@@ -0,0 +1,752 @@
+'use client';
+
+import { type MouseEvent, type ReactNode } from 'react';
+import { ArrowRight, BookOpen, Route, Workflow } from 'lucide-react';
+import { courseHref } from './course-navigation';
+import { modules } from './module-catalog';
+import './course-home.css';
+
+type Navigate = (module: string, lesson?: string) => void;
+
+const moduleDetails = [
+  {
+    slug: 'api',
+    position: '请求入口:浏览器与业务逻辑之间',
+    responsibility:
+      '把一个 Python 函数变成别人能调用的 HTTP 接口,约定输入、返回值、错误和通信方式。',
+    importance:
+      '接口是前后端的共同约定。边界不清,调用方就不知道请求是否成功、如何处理失败。',
+    problem:
+      '参数乱传、错误信息不一致、慢调用卡住服务,以及结果需要逐步展示时怎样通信。',
+    example:
+      '商家创建商品、修改名称和价格、切换上架状态;用这些操作练 CRUD,再用商品咨询演示 SSE 与 WebSocket。',
+    question: '用户的一个操作,怎样成为服务端的一次可靠调用?',
+  },
+  {
+    slug: 'database',
+    position: '持久数据层:任务与业务记录的真源',
+    responsibility:
+      '通过表、约束、索引和事务保存数据;“真源”就是冲突时系统最终以这里的记录为准。',
+    importance:
+      '进程会重启、请求会并发。数据库既要让数据留下来,也要守住“哪些状态允许存在”的规则。',
+    problem:
+      '数据丢失、重复记录、并发覆盖、慢查询、锁等待,以及备份能否真正恢复。',
+    example:
+      '在 shop_lab 从商品与分类需求推导表结构,解释价格为什么按分存、分类为什么拆表,再练事务、索引与恢复。',
+    question: '服务重启后,哪些事实必须还在?',
+  },
+  {
+    slug: 'api-database',
+    position: 'API 与 MySQL 的连接处:会话和事务边界',
+    responsibility:
+      '把接口接入数据库,安排连接的借用与归还,明确何时提交、何时回滚,以及如何升级表结构。',
+    importance:
+      '接口返回“成功”应对应已经提交的事实;重复请求和并发修改不能悄悄破坏业务规则。',
+    problem:
+      '连接泄漏、事务跨请求、重复提交、并发更新冲突,以及“记录写了、任务没发出去”的双写窗口。',
+    example:
+      '长任务受理时,把任务记录和 outbox 待投递事件写入同一事务;课程用持久化商品服务理解这些边界。',
+    question: '一次接口成功,到底承诺了哪些数据已经写入?',
+  },
+  {
+    slug: 'tasks',
+    position: '执行层:任务投递器、队列与独立 worker',
+    responsibility:
+      '先区分协程、线程和进程,再把长任务交给独立执行进程;管理状态、并发、重试与取消。',
+    importance:
+      '“接口先返回”不会自动带来可靠性。任务能否跨进程退出恢复,要依靠持久记录、投递和确认机制。',
+    problem:
+      '请求超时、事件循环阻塞、任务丢失、重复执行、积压,以及 worker 做到一半退出。',
+    example:
+      '商家导出商品清单,API 先返回任务 ID,worker 读取商品快照并生成文件;用这条链路练并发、重试和恢复。',
+    question: '用户不用一直等的工作,之后由谁保证继续执行?',
+  },
+  {
+    slug: 'redis',
+    position: '加速层:API 旁的可重建缓存',
+    responsibility:
+      '把高频读取的结果临时放到 Redis;定义缓存键、有效期、更新失效、容量和故障降级。',
+    importance:
+      '缓存让重复读取更快,但可能短暂落后于数据库。先说清可接受的旧数据窗口,才知道怎样使用它。',
+    problem:
+      '数据库重复承压、热点同时过期、缓存穿透、内存耗尽,以及缓存不可用时请求雪崩。',
+    example:
+      '商品详情被反复读取时使用 Redis 缓存;商家修改商品后处理失效,并以 MySQL 为真源。',
+    question: '哪些数据可以临时读副本,丢了也能重新构建?',
+  },
+  {
+    slug: 'docker',
+    position: '运行包装层:API、worker 和依赖服务的容器',
+    responsibility:
+      '把代码与依赖构建成镜像,用 Compose 组织服务网络、配置、健康检查、持久卷与资源限制。',
+    importance:
+      '同一份代码需要在不同机器上按可重复的方式启动;容器删掉时,数据库数据仍应有独立的保存位置。',
+    problem:
+      '本地能跑而部署失败、服务互相找不到、容器重建丢数据、资源争抢,以及升级无法回滚。',
+    example:
+      '把 API、worker、MySQL、Redis 作为职责不同的服务运行;课程搭建 shop-service 容器环境。',
+    question: '怎样把几个进程及其依赖装成可重复运行的一套环境?',
+  },
+  {
+    slug: 'linux',
+    position: '主机与网络入口:承载进程,对外提供服务',
+    responsibility:
+      '管理运行账户、目录权限、端口、systemd、Nginx、TLS、发布回滚和备份恢复流程。',
+    importance:
+      '程序能启动只是起点。它还要能被安全访问、在故障后恢复,并且在升级时有明确的退路。',
+    problem:
+      '端口不通、权限错误、进程退出、流式响应被代理缓冲、证书配置错误和发布中断。',
+    example:
+      '为商品服务的 HTTP 与流式入口配置反向代理,把 shop-service 部署到独立 Linux 练习机。',
+    question: '从自己的电脑到别人能访问的服务,中间还有哪些工作?',
+  },
+  {
+    slug: 'troubleshooting',
+    position: '横跨各层:日志、指标、追踪与恢复手册',
+    responsibility:
+      '用请求 ID 和任务 ID 串起现场;指标看整体变化,日志看具体事件,追踪看跨组件耗时。',
+    importance:
+      '“很慢”只是症状。先找证据和影响范围,才能决定该限流、回滚,还是修复某个依赖。',
+    problem:
+      '不知道请求卡在哪、CPU 与连接耗尽、慢 SQL、缓存失效、任务积压,以及修好后无法证明恢复。',
+    example:
+      '同一个任务 ID 贯穿 API、投递和 worker,区分“排队久”和“执行慢”;课程进行隔离故障演练。',
+    question: '用户说任务一直转圈,怎样沿着证据找到卡住的那一段?',
+  },
+  {
+    slug: 'distributed',
+    position: '设计视角:所有跨进程、跨机器的连接处',
+    responsibility:
+      '用架构图与故障推演理解扩容、一致性、复制、分片、消息可靠性和服务治理;练习说明设计取舍。',
+    importance:
+      '组件各自正常,不等于协作一定正确。网络超时只能说明没及时收到答复,不能证明对方没执行。',
+    problem:
+      '部分失败、重复消息、数据不一致、流量突增、热点、级联故障,以及系统设计题只会堆组件。',
+    example:
+      '讨论“任务已完成但响应丢失”“数据库成功但投递失败”等场景;12 节课以理论和面试推演为主。',
+    question: '多个组件没有同时成功时,系统该如何继续工作?',
+  },
+  {
+    slug: 'review',
+    position: '课程收尾:逐一检查九个技术模块',
+    responsibility:
+      '用理解问题、情境推演和成果证据检验掌握程度,把薄弱点对应到原课时。',
+    importance:
+      '能跟着做出结果之后,还需要独立解释原因,并在条件变化时作出有依据的判断。',
+    problem:
+      '看答案觉得都懂,合上课件却讲不清、做不出,或不知道还需要补哪一课。',
+    example:
+      '继续用商品查询、编辑和清单导出,闭卷回答问题,核对要点,并记录需要回学的部分。',
+    question: '学完之后,拿什么证明自己已经掌握?',
+  },
+];
+
+function HomeLink({
+  module,
+  navigate,
+  children,
+  className,
+}: {
+  module: string;
+  navigate: Navigate;
+  children: ReactNode;
+  className?: string;
+}) {
+  return (
+    <a
+      href={courseHref(module)}
+      className={className}
+      onClick={(event: MouseEvent<HTMLAnchorElement>) => {
+        if (
+          event.button !== 0 ||
+          event.metaKey ||
+          event.ctrlKey ||
+          event.shiftKey ||
+          event.altKey
+        )
+          return;
+        event.preventDefault();
+        navigate(module);
+      }}
+    >
+      {children}
+    </a>
+  );
+}
+
+function ArchitectureNode({
+  title,
+  lines,
+  module,
+  kind = 'service',
+}: {
+  title: string;
+  lines: string[];
+  module?: string;
+  kind?: 'service' | 'context' | 'queue' | 'storage' | 'runtime';
+}) {
+  const className = `home-architecture-node home-node-${kind}`;
+  const content = (
+    <>
+      <strong>{title}</strong>
+      {lines.map((line) => (
+        <span key={line}>{line}</span>
+      ))}
+    </>
+  );
+
+  return module ? (
+    <a
+      className={className}
+      href={`#home-module-${module}`}
+      aria-label={`查看${title}的课程定位`}
+    >
+      {content}
+    </a>
+  ) : (
+    <div className={className}>{content}</div>
+  );
+}
+
+function ArchitectureLayer({
+  name,
+  responsibility,
+  kind,
+  children,
+  note,
+}: {
+  name: string;
+  responsibility: string;
+  kind: string;
+  children: ReactNode;
+  note?: string;
+}) {
+  return (
+    <div className={`home-architecture-layer home-layer-${kind}`}>
+      <div className="home-layer-heading">
+        <h3>{name}</h3>
+        <p>{responsibility}</p>
+      </div>
+      <div className="home-layer-content">
+        <div className="home-layer-nodes">{children}</div>
+        {note && <p className="home-layer-note">{note}</p>}
+      </div>
+    </div>
+  );
+}
+
+function ArchitectureDiagram() {
+  return (
+    <div className="home-layered-architecture">
+      <div className="home-architecture-stack">
+        <ArchitectureLayer
+          name="客户端层"
+          responsibility="发起操作 · 接收结果"
+          kind="client"
+        >
+          <ArchitectureNode
+            title="客户端"
+            lines={[
+              '浏览器 / API 调用方',
+              '提交请求、查询任务状态、订阅执行进度',
+            ]}
+            kind="context"
+          />
+        </ArchitectureLayer>
+
+        <ArchitectureLayer
+          name="接入层"
+          responsibility="对外入口 · 流量转发"
+          kind="access"
+        >
+          <ArchitectureNode
+            title="07 入口网关 · Nginx / TLS"
+            lines={['接收 HTTPS 请求,通过路由与反向代理转发到 API']}
+            module="linux"
+          />
+        </ArchitectureLayer>
+
+        <ArchitectureLayer
+          name="业务服务层"
+          responsibility="处理请求 · 组织业务"
+          kind="business"
+          note="短请求由 API 调用业务并返回;长任务在同一事务中保存任务与 outbox,提交后返回任务 ID。"
+        >
+          <ArchitectureNode
+            title="01 API 服务"
+            lines={['业务鉴权、参数校验', '同步响应 / 受理长任务']}
+            module="api"
+          />
+          <ArchitectureNode
+            title="Agent 编排*"
+            lines={['规划步骤、管理上下文', '由 API 或 worker 调用']}
+            kind="context"
+          />
+          <ArchitectureNode
+            title="模型 / 工具*"
+            lines={['由 Agent 编排调用', '生成、检索或执行动作']}
+            kind="context"
+          />
+        </ArchitectureLayer>
+
+        <ArchitectureLayer
+          name="异步执行层"
+          responsibility="交接任务 · 独立执行"
+          kind="tasks"
+          note="投递器读取已提交的 outbox;worker 幂等处理、写回 MySQL 后再确认消息,投递与执行均需考虑重试。"
+        >
+          <ArchitectureNode
+            title="04 任务投递器"
+            lines={['读取 outbox 并发送消息', '发送后更新投递状态']}
+            module="tasks"
+            kind="queue"
+          />
+          <ArrowRight
+            className="home-task-arrow"
+            size={18}
+            aria-hidden="true"
+          />
+          <ArchitectureNode
+            title="04 消息队列"
+            lines={['Broker 暂存任务消息', '等待 worker 领取']}
+            module="tasks"
+            kind="queue"
+          />
+          <ArrowRight
+            className="home-task-arrow"
+            size={18}
+            aria-hidden="true"
+          />
+          <ArchitectureNode
+            title="04 Worker"
+            lines={['执行工作 / 调用编排', '幂等处理、写回结果']}
+            module="tasks"
+            kind="queue"
+          />
+        </ArchitectureLayer>
+
+        <ArchitectureLayer
+          name="数据层"
+          responsibility="访问数据 · 保存事实"
+          kind="data"
+          note="API 与 worker 按需访问;缓存未命中时读取 MySQL 真源。Redis 缓存与消息队列承担不同职责。"
+        >
+          <ArchitectureNode
+            title="03 API + 数据库"
+            lines={['管理会话与事务边界', '任务 + outbox 同事务提交']}
+            module="api-database"
+            kind="storage"
+          />
+          <ArchitectureNode
+            title="02 MySQL"
+            lines={['业务数据的持久真源', '商品、任务状态与结果']}
+            module="database"
+            kind="storage"
+          />
+          <ArchitectureNode
+            title="05 Redis 缓存"
+            lines={['保存可重建的查询结果', 'TTL 到期 / 更新时失效']}
+            module="redis"
+            kind="storage"
+          />
+        </ArchitectureLayer>
+
+        <ArchitectureLayer
+          name="运行基础层"
+          responsibility="承载服务 · 部署恢复"
+          kind="runtime"
+          note="承载本地服务及依赖;外部模型与工具服务运行在各自系统中。"
+        >
+          <ArchitectureNode
+            title="06 Docker"
+            lines={['封装 API、worker 与本地依赖', '镜像、容器、网络与持久卷']}
+            module="docker"
+            kind="runtime"
+          />
+          <ArchitectureNode
+            title="07 Linux"
+            lines={['承载进程与容器', '权限、网络、发布与恢复']}
+            module="linux"
+            kind="runtime"
+          />
+        </ArchitectureLayer>
+      </div>
+
+      <aside className="home-crosscutting" aria-label="贯穿各层的能力">
+        <div className="home-crosscutting-heading">
+          <span>贯穿各层</span>
+          <p>从请求入口到运行底座</p>
+        </div>
+        <a
+          href="#home-module-troubleshooting"
+          className="home-crosscutting-card"
+        >
+          <span className="home-crosscutting-number">08</span>
+          <h3>观测与排障</h3>
+          <p>用请求 ID / 任务 ID 串联各层证据。</p>
+          <dl>
+            <div>
+              <dt>日志</dt>
+              <dd>发生了什么</dd>
+            </div>
+            <div>
+              <dt>指标</dt>
+              <dd>整体运行怎样</dd>
+            </div>
+            <div>
+              <dt>追踪</dt>
+              <dd>慢在哪一段</dd>
+            </div>
+          </dl>
+        </a>
+        <a href="#home-module-distributed" className="home-crosscutting-card">
+          <span className="home-crosscutting-number">09</span>
+          <h3>分布式设计</h3>
+          <p>审视跨进程、跨机器协作中的失败与取舍。</p>
+          <ul>
+            <li>超时与部分失败</li>
+            <li>重复消息与幂等</li>
+            <li>一致性与恢复</li>
+          </ul>
+          <span className="home-crosscutting-tag">设计视角 · 理论模块</span>
+        </a>
+      </aside>
+    </div>
+  );
+}
+
+const timeline = [
+  [
+    '提交请求',
+    '商家点击“导出商品清单”。API 校验请求并确定商品快照;这时尚未承诺文件已经生成。',
+    '当前请求',
+  ],
+  [
+    '记录已提交',
+    '在一个数据库事务里写入任务和待投递事件。事务提交后,API 返回 202 与任务 ID。',
+    '受理 ≠ 完成',
+  ],
+  [
+    '任务交接',
+    '投递器读取事件并发送到队列。此时浏览器的原请求已经结束,投递失败可以稍后重试。',
+    '异步开始',
+  ],
+  [
+    '独立执行',
+    'worker 领取任务,把固定的商品快照写入导出文件;完成后保存结果引用,再确认消息。重复消息需要幂等处理。',
+    '可能重试',
+  ],
+  [
+    '读取结果',
+    '客户端拿任务 ID 查询 API,或订阅进度。API 从任务记录判断等待、执行、成功或失败。',
+    '看持久状态',
+  ],
+];
+
+export function CourseHome({ navigate }: { navigate: Navigate }) {
+  const totalLessons = modules.reduce((sum, module) => sum + module.count, 0);
+  return (
+    <article className="course-home" id="course-home">
+      <header className="home-hero">
+        <div className="eyebrow">
+          <span /> AGENT BACKEND · COURSE GUIDE
+        </div>
+        <h1 tabIndex={-1}>
+          先看见整个系统,
+          <br />
+          再学会每一个模块<span className="title-dot">.</span>
+        </h1>
+        <p className="home-lead">
+          从一个容易理解的电商商品服务出发,逐步学会接住请求、保存商品、导出清单,并在失败时恢复。这些后端能力也构成服务端
+          Agent 的基础。
+        </p>
+        <div className="home-actions">
+          <HomeLink
+            module="api"
+            navigate={navigate}
+            className="home-primary-link"
+          >
+            从 API 第一模块开始 <ArrowRight size={17} />
+          </HomeLink>
+          <a href="#architecture" className="home-secondary-link">
+            先看系统全貌 <Workflow size={17} />
+          </a>
+        </div>
+        <dl className="home-course-facts">
+          <div>
+            <dt>{String(modules.length).padStart(2, '0')}</dt>
+            <dd>学习模块</dd>
+          </div>
+          <div>
+            <dt>{totalLessons}</dt>
+            <dd>课时与自测单元</dd>
+          </div>
+          <div>
+            <dt>8 + 1 + 1</dt>
+            <dd>后端实践 + 架构理论 + 复盘自测</dd>
+          </div>
+        </dl>
+      </header>
+
+      <aside className="home-scope-note" aria-label="课程范围">
+        <BookOpen size={21} />
+        <div>
+          <strong>用简单的电商商品服务,走通整个后端链路。</strong>
+          <p>
+            实战聚焦商品管理、分类、评价和清单导出;价格按分存储,上下架用布尔值表示。前
+            8 个模块依次补齐接口、存储、任务、缓存、部署和排障,第 9
+            个模块讲分布式设计理论,第 10
+            个模块逐一复盘并自测掌握程度。下图同时标出 Agent 的扩展位置;Agent
+            编排、模型与工具、完整业务鉴权用于说明所在位置,不属于本课已实现的完整
+            Agent / RAG 项目。
+          </p>
+        </div>
+      </aside>
+
+      <section id="architecture" aria-labelledby="home-architecture-heading">
+        <div className="home-section-heading">
+          <span>01 / SYSTEM MAP</span>
+          <h2 id="home-architecture-heading">把课程放进一张分层架构图</h2>
+          <p>
+            从客户端到运行底座,按职责逐层理解系统;观测与设计能力贯穿各层。节点编号对应下方课程模块,点击可查看定位。
+          </p>
+        </div>
+        <figure className="home-architecture-figure">
+          <div className="home-diagram-legend">
+            <span>
+              <i className="home-legend-module" /> 课程覆盖模块
+            </span>
+            <span>
+              <i className="home-legend-task" /> 异步任务交接
+            </span>
+            <span>
+              <i className="home-legend-context" /> * Agent 扩展上下文
+            </span>
+          </div>
+          <ArchitectureDiagram />
+          <figcaption>
+            分层表示职责与支撑关系,请求按需访问相关组件,并非逐层依次执行。橙色箭头表示异步任务交接;API
+            负责业务鉴权,Nginx 负责入口代理。带 * 的 Agent
+            编排、模型与工具用于说明扩展位置,不属于本课已实现的完整系统。
+          </figcaption>
+        </figure>
+        <div className="home-reading-paths">
+          <article>
+            <span className="home-reading-number">A</span>
+            <h3>短请求:当场取回结果</h3>
+            <p>
+              客户端 → 网关 → API → 数据库、缓存或业务调用 →
+              原路返回。用户等待的是这一次请求完成;慢依赖仍会占用请求的时间预算。
+            </p>
+          </article>
+          <article>
+            <span className="home-reading-number">B</span>
+            <h3>长任务:先受理,再执行</h3>
+            <p>
+              API 写入任务和 outbox → 返回任务 ID。投递器 → 队列 → worker →
+              写回结果。用户之后再查询进度,接收成功并不等于执行成功。
+            </p>
+          </article>
+          <article>
+            <span className="home-reading-number">C</span>
+            <h3>运行与可靠性:贯穿整张图</h3>
+            <p>
+              Docker 管运行单元,Linux
+              管主机与入口,观测帮助找故障。分布式设计关注它们之间的网络、重复、超时与不一致。
+            </p>
+          </article>
+        </div>
+        <p className="home-term-note">
+          <strong>两个容易混淆的词:</strong>队列像“待办传送带”,worker
+          是实际做事的进程;Redis 可以用来缓存,也可以承担某些队列的 broker
+          角色,但两种用途的丢失容忍度、持久化和内存策略不同,不能因产品名相同就视为同一种数据。
+        </p>
+      </section>
+
+      <section id="modules" aria-labelledby="home-modules-heading">
+        <div className="home-section-heading">
+          <span>02 / MODULE GUIDE</span>
+          <h2 id="home-modules-heading">每个模块,补上哪一种能力</h2>
+          <p>
+            前九个模块对应上图中的技术位置,最后一个模块用于复盘自测,检查这些能力能否独立运用。
+          </p>
+        </div>
+        <div className="home-module-grid">
+          {moduleDetails.map((detail, index) => {
+            const catalogEntry = modules.find(
+              (candidate) => candidate.slug === detail.slug,
+            );
+            if (!catalogEntry) return null;
+            return (
+              <article
+                key={detail.slug}
+                className="home-module-card"
+                id={`home-module-${detail.slug}`}
+              >
+                <div className="home-module-card-top">
+                  <span className="home-module-number">
+                    {String(index + 1).padStart(2, '0')}
+                  </span>
+                  <span>
+                    {catalogEntry.count}{' '}
+                    {detail.slug === 'review' ? '自测单元' : '课时'} ·{' '}
+                    {detail.slug === 'distributed'
+                      ? '理论与面试'
+                      : catalogEntry.kicker}
+                  </span>
+                  <catalogEntry.icon size={19} />
+                </div>
+                <h3>{catalogEntry.name}</h3>
+                <p className="home-module-question">{detail.question}</p>
+                <dl>
+                  <div>
+                    <dt>所在位置</dt>
+                    <dd>{detail.position}</dd>
+                  </div>
+                  <div>
+                    <dt>负责什么</dt>
+                    <dd>{detail.responsibility}</dd>
+                  </div>
+                  <div>
+                    <dt>为何重要</dt>
+                    <dd>{detail.importance}</dd>
+                  </div>
+                  <div>
+                    <dt>解决问题</dt>
+                    <dd>{detail.problem}</dd>
+                  </div>
+                  <div>
+                    <dt>放进实例</dt>
+                    <dd>{detail.example}</dd>
+                  </div>
+                </dl>
+                <HomeLink
+                  module={detail.slug}
+                  navigate={navigate}
+                  className="home-module-entry"
+                >
+                  进入{catalogEntry.name}
+                  <ArrowRight size={16} />
+                </HomeLink>
+              </article>
+            );
+          })}
+        </div>
+      </section>
+
+      <section id="journey" aria-labelledby="home-task-story-heading">
+        <div className="home-section-heading">
+          <span>03 / ONE LONG TASK</span>
+          <h2 id="home-task-story-heading">把“导出一份商品清单”走一遍</h2>
+          <p>
+            这是把多个模块连起来的设计示例:关键不在“后台”两个字,而在每个阶段承诺了什么、失败后由谁接手。
+          </p>
+        </div>
+        <ol className="home-task-timeline">
+          {timeline.map(([title, description, state], index) => (
+            <li key={title}>
+              <span className="home-timeline-index">{index + 1}</span>
+              <div>
+                <div className="home-timeline-title">
+                  <h3>{title}</h3>
+                  <span>{state}</span>
+                </div>
+                <p>{description}</p>
+              </div>
+            </li>
+          ))}
+        </ol>
+        <div className="home-failure-note">
+          <strong>如果 worker 刚写完结果就退出了?</strong>
+          <p>
+            消息可能尚未确认,随后会被再次投递。所以“至少一次投递”仍需业务幂等:同一个任务
+            ID
+            再次到达时,要识别已经提交的结果。若任务会向外部系统产生副作用,还需要对方支持幂等键或配套的核对与补偿流程。
+          </p>
+          <HomeLink module="distributed" navigate={navigate}>
+            去分布式模块推演更多失败场景 <ArrowRight size={15} />
+          </HomeLink>
+        </div>
+      </section>
+
+      <section id="learning-path" aria-labelledby="home-learning-path-heading">
+        <div className="home-section-heading">
+          <span>04 / LEARNING ROUTE</span>
+          <h2 id="home-learning-path-heading">没有后端经验,就按这条路走</h2>
+          <p>
+            会基础
+            Python、能执行终端命令即可从第一模块开始。数据库、Docker、Linux
+            环境在对应模块准备,每一步都带着上一阶段的问题进入下一阶段。
+          </p>
+        </div>
+        <ol className="home-learning-route">
+          <li>
+            <Route size={20} />
+            <div>
+              <h3>先让请求有入口,数据能保存</h3>
+              <p>
+                01 API → 02 数据库 → 03 API +
+                数据库。先完成一个能正确提交、查询与修改数据的服务。
+              </p>
+            </div>
+            <HomeLink module="api" navigate={navigate}>
+              开始 <ArrowRight size={16} />
+            </HomeLink>
+          </li>
+          <li>
+            <Workflow size={20} />
+            <div>
+              <h3>再处理等待、并发与重复读取</h3>
+              <p>
+                04 异步任务 + 05 Redis。两者都建立在持久化 API
+                之上;任务模块进入队列部分前,先准备 Redis 基础环境。
+              </p>
+            </div>
+            <HomeLink module="tasks" navigate={navigate}>
+              进入 <ArrowRight size={16} />
+            </HomeLink>
+          </li>
+          <li>
+            <BookOpen size={20} />
+            <div>
+              <h3>让服务运行起来,也能查清故障</h3>
+              <p>
+                06 Docker → 07 Linux → 08
+                排障。用部署、停止、升级和恢复检验前面实现的边界。
+              </p>
+            </div>
+            <HomeLink module="docker" navigate={navigate}>
+              进入 <ArrowRight size={16} />
+            </HomeLink>
+          </li>
+          <li>
+            <Workflow size={20} />
+            <div>
+              <h3>把组件连起来,解释架构为什么这样设计</h3>
+              <p>
+                09
+                分布式系统设计。用已有组件经验讨论容量、数据与失败;围绕理论和面试场景展开,不安排实战代码。
+              </p>
+            </div>
+            <HomeLink module="distributed" navigate={navigate}>
+              进入 <ArrowRight size={16} />
+            </HomeLink>
+          </li>
+          <li>
+            <BookOpen size={20} />
+            <div>
+              <h3>最后,检验每个模块是否真的掌握</h3>
+              <p>
+                10
+                复盘与自测。每个技术模块对应一个自测单元:独立回答问题、推演变化、核对掌握证据,再按缺口回学。
+              </p>
+            </div>
+            <HomeLink module="review" navigate={navigate}>
+              开始自测 <ArrowRight size={16} />
+            </HomeLink>
+          </li>
+        </ol>
+      </section>
+    </article>
+  );
+}

+ 2 - 1
app/course-navigation.ts

@@ -1,5 +1,6 @@
 export type CourseRoute = { module: string; lesson?: string };
 export function courseHref(module: string, lesson?: string) {
+  if (module === 'home' && !lesson) return '?';
   const query = new URLSearchParams();
   if (module !== 'api' || !lesson) query.set('module', module);
   if (lesson) query.set('lesson', lesson);
@@ -8,7 +9,7 @@ export function courseHref(module: string, lesson?: string) {
 export function parseCourseQuery(search: string): CourseRoute {
   const query = new URLSearchParams(search);
   return {
-    module: query.get('module') || 'api',
+    module: query.get('module') || (query.get('lesson') ? 'api' : 'home'),
     lesson: query.get('lesson') || undefined,
   };
 }

+ 414 - 81
app/course.tsx

@@ -1,5 +1,11 @@
 'use client';
-import { useEffect, useState, type MouseEvent, type ReactNode } from 'react';
+import {
+  useEffect,
+  useRef,
+  useState,
+  type MouseEvent,
+  type ReactNode,
+} from 'react';
 import Image from 'next/image';
 import {
   ArrowUpRight,
@@ -16,6 +22,7 @@ import {
   RotateCcw,
   ShieldCheck,
   GitBranch,
+  House,
 } from 'lucide-react';
 import {
   Sidebar,
@@ -38,29 +45,47 @@ import {
 } from '@/components/ui/table';
 import { ProtocolDemo, HttpDemo, AsyncDemo } from './demos';
 import { TaskDeliveryDemo } from './task-delivery-demo';
+import { CourseHome } from './course-home';
+import { ReviewPage } from './review-page';
+import {
+  ModuleDiagrams,
+  LessonDiagrams,
+  hasLessonDiagrams,
+} from './teaching-diagrams';
 import { courseHref, parseCourseQuery } from './course-navigation';
 import { modules, getModule, type ModuleInfo } from './module-catalog';
-import type { Lesson, Video } from './lesson-types';
+import type { Lesson, TeachingLesson, Video } from './lesson-types';
 import apiData from './lessons.json';
 import videoData from './videos.json';
 const videos = videoData as Record<string, Video>;
 type Navigate = (module: string, lesson?: string) => void;
 type Route = { module: string; lesson?: string };
 const overviewSections = [
+  ['module-diagrams', '模块图解'],
   ['path', '学习路径'],
   ['context', '工程边界'],
   ['ready', '完成标准'],
 ];
 const lessonSections = [
-  ['objectives', '本节目标'],
-  ['concepts', '必要概念'],
-  ['steps', '动手实现'],
-  ['checks', '验收结果'],
-  ['production', '生产约束与故障演练'],
-  ['pitfalls', '常见错误'],
-  ['exercise', '扩展练习'],
+  ['start', '从问题开始'],
+  ['objectives', '这节课要完成什么'],
+  ['steps', '一步步完成案例'],
+  ['checks', '检查学习结果'],
+  ['diagrams', '把过程连起来'],
+  ['production', '进一步验证'],
+  ['pitfalls', '遇到问题时查这里'],
+  ['interview', '面试问答'],
+  ['exercise', '自己试一次'],
+  ['concepts', '核心概念回顾'],
   ['references', '技术文档'],
 ];
+const reviewSections = [
+  ['review-start', '如何自测'],
+  ['review-questions', '核心理解问题'],
+  ['review-challenges', '情境推演'],
+  ['review-mastery', '掌握标准与证据'],
+  ['review-result', '自测结果与回学'],
+];
 function routeLink(module: string, lesson?: string) {
   return courseHref(module, lesson);
 }
@@ -77,7 +102,7 @@ function groups(lessons: Lesson[]) {
   return result;
 }
 function LocalLink({
-  module = 'api',
+  module = 'home',
   lesson,
   navigate,
   children,
@@ -137,9 +162,20 @@ function Navigation({
       </SidebarHeader>
       <SidebarContent>
         <div className="nav-caption">
-          课程目录<span>08 MODULES</span>
+          课程目录<span>{String(modules.length).padStart(2, '0')} MODULES</span>
         </div>
         <nav className="module-nav" aria-label="课程模块">
+          <LocalLink
+            module="home"
+            navigate={go}
+            className={`module-link ${route.module === 'home' ? 'selected' : ''}`}
+            aria-current={
+              route.module === 'home' && !route.lesson ? 'page' : undefined
+            }
+          >
+            <House size={18} />
+            <span>课程总览</span>
+          </LocalLink>
           {modules.map((m, i) => (
             <LocalLink
               key={m.slug}
@@ -188,7 +224,7 @@ function Navigation({
         <div className="sidebar-note">
           <BookOpen size={17} />
           <div>
-            后端工程实践<small>实现 · 验证 · 故障恢复</small>
+            后端工程与系统设计<small>理解 · 实现 · 验证 · 恢复</small>
           </div>
         </div>
       </SidebarFooter>
@@ -196,7 +232,8 @@ function Navigation({
   );
 }
 export default function Course() {
-  const [route, setRoute] = useState<Route>({ module: 'api' });
+  const pendingNavigationFocus = useRef(false);
+  const [route, setRoute] = useState<Route>({ module: 'home' });
   const [data, setData] = useState<Record<string, Lesson[]>>({
     api: apiData as Lesson[],
   });
@@ -205,7 +242,9 @@ export default function Course() {
   const current = getModule(route.module);
   const lessons = current ? data[current.slug] : undefined;
   const lesson = lessons?.find((l) => l.id === route.lesson);
-  const invalid = !current || !!(lessons && route.lesson && !lesson);
+  const isHome = route.module === 'home' && !route.lesson;
+  const invalid =
+    !isHome && (!current || !!(lessons && route.lesson && !lesson));
   useEffect(() => {
     const read = () => {
       setRoute(readRoute());
@@ -225,7 +264,21 @@ export default function Course() {
         if (
           !Array.isArray(payload) ||
           payload.length !== current.count ||
-          payload.some((l) => !l.id || !l.title || !Array.isArray(l.steps))
+          payload.some(
+            (l) =>
+              !l.id ||
+              !l.title ||
+              (current.slug === 'review'
+                ? l.kind !== 'review' ||
+                  !Array.isArray(l.questions) ||
+                  l.questions.length !== 6 ||
+                  !Array.isArray(l.challenges) ||
+                  l.challenges.length !== 2 ||
+                  !Array.isArray(l.mastery) ||
+                  l.mastery.length !== 4 ||
+                  !l.intro
+                : !Array.isArray(l.steps)),
+          )
         )
           throw new Error('课件文件不完整,请重试。');
         setData((previous) => ({ ...previous, [current.slug]: payload }));
@@ -237,20 +290,91 @@ export default function Course() {
     return () => controller.abort();
   }, [current, lessons, retry]);
   useEffect(() => {
-    document.title = `${lesson?.title || current?.name || '课件'} · 术核 AI 后端工程`;
-  }, [lesson, current]);
+    document.title = `${isHome ? '课程总览' : lesson?.title || current?.name || '课件'} · 术核 AI 后端工程`;
+  }, [lesson, current, isHome]);
+  useEffect(() => {
+    // Lazy-loaded lessons are not in the DOM when a bookmarked fragment opens.
+    const scrollToSection = () => {
+      if (!location.hash) return;
+      let id: string;
+      try {
+        id = decodeURIComponent(location.hash.slice(1));
+      } catch {
+        return;
+      }
+      document
+        .getElementById(id)
+        ?.scrollIntoView({ block: 'start', behavior: 'instant' });
+    };
+    const frame = requestAnimationFrame(scrollToSection);
+    window.addEventListener('hashchange', scrollToSection);
+    return () => {
+      cancelAnimationFrame(frame);
+      window.removeEventListener('hashchange', scrollToSection);
+    };
+  }, [route.module, route.lesson, lessons]);
+  useEffect(() => {
+    if (
+      !pendingNavigationFocus.current ||
+      (!isHome && !invalid && !error && !lessons)
+    )
+      return;
+    const frame = requestAnimationFrame(() => {
+      const heading = document.querySelector<HTMLElement>('h1');
+      if (heading) {
+        heading.focus({ preventScroll: true });
+        pendingNavigationFocus.current = false;
+      }
+    });
+    return () => cancelAnimationFrame(frame);
+  }, [route.module, route.lesson, lessons, isHome, invalid, error]);
   const navigate: Navigate = (module, id) => {
+    pendingNavigationFocus.current = true;
     setRoute({ module, lesson: id });
     setError('');
     history.pushState({}, '', routeLink(module, id));
     window.scrollTo({ top: 0, behavior: 'instant' });
-    requestAnimationFrame(() =>
-      document.querySelector<HTMLElement>('h1')?.focus({ preventScroll: true }),
-    );
   };
-  const toc = lesson
-    ? lessonSections.filter(([id]) => id !== 'production' || lesson.production)
-    : overviewSections;
+  const toc = isHome
+    ? [
+        ['architecture', 'Agent 架构全景'],
+        ['modules', '模块定位与作用'],
+        ['journey', '一次长任务的旅程'],
+        ['learning-path', '推荐学习顺序'],
+      ]
+    : lesson?.kind === 'review'
+      ? reviewSections
+      : lesson
+        ? lessonSections
+            .filter(
+              ([id]) =>
+                (id !== 'start' || lesson.intro) &&
+                (id !== 'production' || lesson.production) &&
+                (id !== 'interview' || lesson.interview) &&
+                (id !== 'diagrams' ||
+                  hasLessonDiagrams(route.module, lesson.id)),
+            )
+            .map(([id, label]) => [
+              id,
+              lesson.kind === 'theory'
+                ? (
+                    {
+                      steps: '一步步推演场景',
+                      checks: '检查推演结果',
+                      pitfalls: '常见误区',
+                      exercise: '自己设计一次',
+                      references: '延伸阅读',
+                    } as Record<string, string>
+                  )[id] || label
+                : label,
+            ])
+        : route.module === 'review'
+          ? [
+              ['path', '选择自测模块'],
+              ['context', '如何判断掌握'],
+              ['ready', '总体完成标准'],
+            ]
+          : overviewSections;
   return (
     <SidebarProvider
       style={{ '--sidebar-width': '270px' } as React.CSSProperties}
@@ -265,7 +389,7 @@ export default function Course() {
             <SidebarTrigger aria-label="展开或收起课程目录" />
             <LocalLink navigate={navigate}>后端工程</LocalLink>
             <span>/</span>
-            <strong>{current?.name || '课件'}</strong>
+            <strong>{isHome ? '课程总览' : current?.name || '课件'}</strong>
             {lesson && (
               <>
                 <span>/</span>
@@ -283,9 +407,11 @@ export default function Course() {
             <ArrowUpRight size={15} />
           </a>
         </header>
-        <div className="document-grid">
+        <div className={`document-grid${isHome ? ' document-grid-home' : ''}`}>
           <main id="main-content">
-            {invalid ? (
+            {isHome ? (
+              <CourseHome navigate={navigate} />
+            ) : invalid ? (
               <div className="pending-module">
                 <h1 tabIndex={-1}>未找到该课件</h1>
                 <p>该模块或课时不存在,请从目录重新选择。</p>
@@ -296,7 +422,7 @@ export default function Course() {
               </div>
             ) : error ? (
               <div className="load-error" role="alert">
-                <h1 tabIndex={-1}>{current.name}</h1>
+                <h1 tabIndex={-1}>{current?.name}</h1>
                 <p>{error}</p>
                 <Button
                   onClick={() => {
@@ -308,9 +434,9 @@ export default function Course() {
                   重新载入
                 </Button>
               </div>
-            ) : !lessons ? (
+            ) : !lessons || !current ? (
               <div aria-busy="true" aria-label="正在载入课件">
-                <div className="eyebrow">{current.name}</div>
+                <div className="eyebrow">{current?.name}</div>
                 <Skeleton className="h-10 w-3/4 my-7" />
                 <Skeleton className="h-5 w-full mb-3" />
                 <Skeleton className="h-5 w-4/5 mb-12" />
@@ -318,6 +444,13 @@ export default function Course() {
                   <Skeleton key={n} className="h-24 w-full my-4" />
                 ))}
               </div>
+            ) : lesson?.kind === 'review' ? (
+              <ReviewPage
+                key={`${route.module}/${lesson.id}`}
+                lesson={lesson}
+                lessons={lessons}
+                navigate={navigate}
+              />
             ) : lesson ? (
               <LessonPage
                 key={`${route.module}/${lesson.id}`}
@@ -335,8 +468,10 @@ export default function Course() {
             )}
           </main>
           <aside className="toc" aria-label="本页导航">
-            <p>{lesson ? '本节导航' : '本模块导航'}</p>
+            <p>{isHome ? '课程导览' : lesson ? '本节导航' : '本模块导航'}</p>
             {!invalid &&
+              !error &&
+              (isHome || !!lessons) &&
               toc.map(([id, label]) => (
                 <a key={id} href={`#${id}`}>
                   {label}
@@ -345,7 +480,10 @@ export default function Course() {
             <div className="toc-note">
               <span className="tiny-label">学习准备</span>
               <p>
-                {current?.prerequisites.map((p) => (
+                {(isHome
+                  ? ['有 Python 基础即可开始', '先看全景,再逐模块深入']
+                  : current?.prerequisites
+                )?.map((p) => (
                   <span key={p} className="prerequisite-line">
                     {p}
                   </span>
@@ -376,7 +514,7 @@ export default function Course() {
         </div>
         <footer className="page-footer">
           <span>术核 AI · Agent 开发前置知识</span>
-          <span>接口 / 数据 / 并发 / 交付 / 运维</span>
+          <span>接口 / 数据 / 并发 / 交付 / 运维 / 架构</span>
         </footer>
       </div>
     </SidebarProvider>
@@ -393,6 +531,8 @@ function Overview({
 }) {
   const index = modules.findIndex((x) => x.slug === m.slug);
   const next = modules[index + 1];
+  const theory = m.slug === 'distributed';
+  const review = m.slug === 'review';
   return (
     <>
       <div className="eyebrow">
@@ -407,8 +547,19 @@ function Overview({
       <p className="lead">{m.description}</p>
       <div className="meta-row">
         <span>{m.stack}</span>
-        <span>{lessons.length} 节实战课</span>
-        <span>{m.slug === 'api' ? '接口基础' : '生产系统实践'}</span>
+        <span>
+          {lessons.length}{' '}
+          {review ? '个自测单元' : `节${theory ? '理论课' : '实战课'}`}
+        </span>
+        <span>
+          {review
+            ? '逐模块检验理解与应用'
+            : theory
+              ? '架构设计与面试'
+              : m.slug === 'api'
+                ? '接口基础'
+                : '生产系统实践'}
+        </span>
       </div>
       <div className="overview-actions">
         <LocalLink
@@ -417,7 +568,7 @@ function Overview({
           navigate={navigate}
           className="primary-link"
         >
-          开始第一节
+          {review ? '开始第一个自测' : '开始第一节'}
           <ArrowRight size={16} />
         </LocalLink>
         <a
@@ -436,16 +587,25 @@ function Overview({
         )}
       </div>
       <div className="module-intro">
-        <span className="section-index">PROJECT</span>
+        <span className="section-index">
+          {review ? 'REVIEW' : theory ? 'DESIGN' : 'PROJECT'}
+        </span>
         <div>
           <h3>{m.project}</h3>
           <p>{m.scope}</p>
         </div>
       </div>
+      {!review && <ModuleDiagrams module={m.slug} />}
       <section id="path">
         <div className="section-heading">
-          <h2>学习路径</h2>
-          <span>概念 → 实现 → 验收 → 故障演练</span>
+          <h2>{review ? '选择要自测的模块' : '学习路径'}</h2>
+          <span>
+            {review
+              ? '独立回答 → 对照要点 → 验证掌握 → 回学'
+              : theory
+                ? '问题 → 推演 → 图解 → 概念回顾'
+                : '问题 → 实践 → 验证 → 概念回顾'}
+          </span>
         </div>
         <div className="curriculum">
           {groups(lessons).map((g, i) => (
@@ -475,7 +635,12 @@ function Overview({
                       <strong>{l.title}</strong>
                       <p>{l.summary}</p>
                     </div>
-                    {l.demo && <span className="interactive-badge">交互</span>}
+                    {l.kind !== 'review' &&
+                      (l.demo || hasLessonDiagrams(m.slug, l.id)) && (
+                        <span className="interactive-badge">
+                          {l.demo ? '交互' : '图解'}
+                        </span>
+                      )}
                     <ChevronRight size={15} />
                   </LocalLink>
                 ))}
@@ -486,10 +651,34 @@ function Overview({
       </section>
       <section id="context">
         <div className="section-heading">
-          <h2>工程边界</h2>
+          <h2>{review ? '如何判断掌握' : '工程边界'}</h2>
           <span>{m.stack}</span>
         </div>
-        {m.slug === 'api' ? (
+        {review ? (
+          <div className="engineering-context">
+            <div className="context-heading">
+              <ShieldCheck size={22} />
+              <h3>能解释,能换个条件推演,也能拿出证据</h3>
+            </div>
+            <p>
+              每个单元先独立回答六个理解问题和两个情境题,再展开参考要点。只在合上要点后仍能说明原因、边界和验证方式时,标记为独立完成。
+            </p>
+            <div className="context-columns">
+              <div>
+                <span>本轮达到标准</span>
+                <p>
+                  八项回答都能独立完成,四项掌握证据均已核对。操作类需要自己的运行记录,理论类需要完整的设计或故障推演。
+                </p>
+              </div>
+              <div>
+                <span>发现缺口之后</span>
+                <p>
+                  沿题目下方链接回到原课时,补做相关实验或推演,再回来闭卷回答。页面汇总你的自评,不自动判断文字答案的正确性。
+                </p>
+              </div>
+            </div>
+          </div>
+        ) : m.slug === 'api' ? (
           <>
             <ProtocolDemo />
             <p className="section-caption">
@@ -498,6 +687,26 @@ function Overview({
             </p>
             <ApiEndpoints />
           </>
+        ) : theory ? (
+          <div className="engineering-context">
+            <div className="context-heading">
+              <GitBranch size={22} />
+              <h3>从业务约束推导方案,再讨论失效边界</h3>
+            </div>
+            <p>
+              本模块通过架构图、故障时间线、设计练习和面试问答展开。无需安装服务或运行代码;阅读时先判断业务允许多旧的数据、能等待多久、失败后能否重试。
+            </p>
+            <div className="context-columns">
+              <div>
+                <span>设计依据</span>
+                <p>业务不变量、容量估算、一致性与可用性目标。</p>
+              </div>
+              <div>
+                <span>方案取舍</span>
+                <p>正常路径、失败路径、运维成本与恢复条件。</p>
+              </div>
+            </div>
+          </div>
         ) : (
           <div className="engineering-context">
             <div className="context-heading">
@@ -540,7 +749,13 @@ function Overview({
       <section id="ready">
         <div className="section-heading">
           <h2>模块完成标准</h2>
-          <span>以行为和数据验证结果</span>
+          <span>
+            {review
+              ? '用具体证据支持自评'
+              : theory
+                ? '说清选择、代价与适用边界'
+                : '以行为和数据验证结果'}
+          </span>
         </div>
         <ul className="check-list">
           {m.outcomes.map((o) => (
@@ -550,6 +765,19 @@ function Overview({
             </li>
           ))}
         </ul>
+        {!review && (
+          <div className="overview-actions">
+            <LocalLink
+              module="review"
+              lesson={String(index + 1).padStart(2, '0')}
+              navigate={navigate}
+              className="secondary-link"
+            >
+              自测本模块:理解问题与掌握证据
+              <ArrowRight size={16} />
+            </LocalLink>
+          </div>
+        )}
         {next && (
           <LocalLink
             module={next.slug}
@@ -637,8 +865,8 @@ function ApiEndpoints() {
         <TableBody>
           {[
             ['GET /health', '健康检查'],
-            ['GET / POST /notes', '查询与创建笔记'],
-            ['GET / PUT / DELETE /notes/{id}', '读取、完整替换与删除'],
+            ['GET / POST /products', '查询与创建商品'],
+            ['GET / PUT / DELETE /products/{id}', '读取、完整替换与删除'],
             ['GET /private', '请求头依赖与鉴权'],
             ['GET /slow · GET /upstream-demo', '异步等待、上游调用与超时'],
             ['POST /generate · GET /stream', '完整 JSON 与 SSE 流式响应'],
@@ -657,7 +885,7 @@ function ApiEndpoints() {
         <FileCode2 size={24} />
         <div>
           <h3>可运行的 FastAPI 参考实现</h3>
-          <p>含版本依赖、浏览器客户端与 30 项测试。</p>
+          <p>含版本依赖、浏览器客户端与 41 项商品服务测试。</p>
         </div>
         <a href="/downloads/api-lab.zip" download className="secondary-link">
           下载 ZIP
@@ -797,11 +1025,12 @@ function LessonPage({
 }: {
   module: ModuleInfo;
   lessons: Lesson[];
-  lesson: Lesson;
+  lesson: TeachingLesson;
   navigate: Navigate;
 }) {
   const index = lessons.findIndex((x) => x.id === l.id);
   const nextModule = modules[modules.findIndex((x) => x.slug === m.slug) + 1];
+  const theory = l.kind === 'theory';
   return (
     <article className="lesson-article">
       <LocalLink module={m.slug} navigate={navigate} className="back-link">
@@ -819,11 +1048,51 @@ function LessonPage({
       <p className="lead">{l.summary}</p>
       <div className="meta-row">
         <span>{m.name}</span>
-        <span>{l.production ? '生产系统实践' : '实战课件'}</span>
-        <span>{l.demo ? '含交互演示' : '含代码与验收'}</span>
+        <span>
+          {theory ? '理论与面试' : l.production ? '生产系统实践' : '实战课件'}
+        </span>
+        <span>
+          {theory
+            ? '图解 · 场景推演 · 设计取舍'
+            : l.demo
+              ? '含交互演示'
+              : '含代码与验收'}
+        </span>
       </div>
+      {l.intro && (
+        <section id="start" className="lesson-start">
+          <h2>从一个问题开始</h2>
+          <p className="lesson-scenario">{l.intro.scenario}</p>
+          <div className="lesson-outcome">
+            <span>这次的终点</span>
+            <p>{l.intro.outcome}</p>
+          </div>
+          <h3>开始前,确认你在这里</h3>
+          <ul>
+            {l.intro.startingPoint.map((item) => (
+              <li key={item}>{item}</li>
+            ))}
+          </ul>
+          <LocalLink
+            module={m.slug}
+            lesson={index > 0 ? lessons[index - 1].id : undefined}
+            navigate={navigate}
+            className="lesson-start-link"
+          >
+            <ArrowLeft size={14} />
+            {index > 0
+              ? `需要时复习上一课:${lessons[index - 1].title}`
+              : '查看本模块的学习准备'}
+          </LocalLink>
+          <p className="lesson-reading-guide">
+            {theory
+              ? '先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。'
+              : '每次只做一个步骤,先核对结果,再读原因。结果不一致时,先排查当前步骤,再继续往下。'}
+          </p>
+        </section>
+      )}
       <section id="objectives" className="objectives">
-        <h2>本节目标</h2>
+        <h2>这节课要完成什么</h2>
         <ul>
           {l.objectives.map((o, i) => (
             <li key={o}>
@@ -834,33 +1103,12 @@ function LessonPage({
         </ul>
       </section>
       <VideoBlock module={m.slug} id={l.id} />
-      <section id="concepts">
-        <div className="section-heading">
-          <h2>必要概念</h2>
-          <span>明确实现的行为与边界</span>
-        </div>
-        {l.concepts.map((c, i) => (
-          <div className="concept" key={c.title}>
-            <h3>
-              <span>{i + 1}.</span>
-              {c.title}
-            </h3>
-            <p>{c.body}</p>
-          </div>
-        ))}
-        {l.demo === 'http' ? (
-          <HttpDemo />
-        ) : l.demo === 'async' ? (
-          <AsyncDemo />
-        ) : l.demo === 'protocol' ? (
-          <ProtocolDemo />
-        ) : null}
-        {m.slug === 'tasks' && l.id === '09' && <TaskDeliveryDemo />}
-      </section>
       <section id="steps">
         <div className="section-heading">
-          <h2>动手实现</h2>
-          <span>{l.steps.length} 个步骤</span>
+          <h2>{theory ? '一步步推演场景' : '一步步完成案例'}</h2>
+          <span>
+            {l.steps.length} 个{theory ? '分析环节' : '步骤'}
+          </span>
         </div>
         {l.steps.map((s, i) => (
           <div className="practice-step" key={`${s.title}-${i}`}>
@@ -872,13 +1120,27 @@ function LessonPage({
             {s.code && (
               <CodeBlock code={s.code} lang={s.lang} filename={s.filename} />
             )}
+            {s.expected && (
+              <div className="step-observation">
+                <h4>{theory ? '对照你的推演' : '你应该看到什么'}</h4>
+                <p>{s.expected}</p>
+              </div>
+            )}
+            {s.explanation && (
+              <div className="step-explanation">
+                <h4>{theory ? '从这里理解原因' : '刚才发生了什么'}</h4>
+                <p>{s.explanation}</p>
+              </div>
+            )}
           </div>
         ))}
       </section>
       <section id="checks">
         <div className="section-heading">
-          <h2>验收结果</h2>
-          <span>核对响应、日志与数据</span>
+          <h2>{theory ? '检查推演结果' : '检查学习结果'}</h2>
+          <span>
+            {theory ? '能否解释因果、代价与边界' : '核对响应、日志与数据'}
+          </span>
         </div>
         <ul className="check-list">
           {l.checks.map((c) => (
@@ -889,12 +1151,24 @@ function LessonPage({
           ))}
         </ul>
       </section>
+      <LessonDiagrams module={m.slug} lesson={l.id} />
+      {l.demo === 'http' ? (
+        <HttpDemo />
+      ) : l.demo === 'async' ? (
+        <AsyncDemo />
+      ) : l.demo === 'protocol' ? (
+        <ProtocolDemo />
+      ) : null}
+      {m.slug === 'tasks' && l.id === '09' && <TaskDeliveryDemo />}
       {l.production && (
         <section id="production" className="production-panel">
           <div className="section-heading">
-            <h2>生产约束与故障演练</h2>
+            <h2>进一步验证:约束与故障</h2>
             <GitBranch size={21} />
           </div>
+          <p className="learning-section-note">
+            先通过上面的学习结果检查,再做这一组实验,观察条件变化以后原来的办法是否仍然有效。
+          </p>
           <div className="production-scenario">
             <span>使用场景</span>
             <p>{l.production.scenario}</p>
@@ -922,7 +1196,7 @@ function LessonPage({
       )}
       <section id="pitfalls">
         <div className="section-heading">
-          <h2>常见错误</h2>
+          <h2>{theory ? '常见误区' : '遇到问题时查这里'}</h2>
           <span>现象 → 原因 → 处理</span>
         </div>
         <div className="pitfall-list">
@@ -941,9 +1215,31 @@ function LessonPage({
           ))}
         </div>
       </section>
+      {l.interview && (
+        <section id="interview" className="exercise interview-panel">
+          <div className="section-heading">
+            <h2>面试问答</h2>
+            <span>先独立回答,再展开参考思路</span>
+          </div>
+          <h3>{l.interview.question}</h3>
+          <details>
+            <summary>
+              查看回答思路
+              <ChevronRight size={15} />
+            </summary>
+            <div className="answer-text">{l.interview.answer}</div>
+          </details>
+          <h3 className="interview-followups">继续追问</h3>
+          <ul className="decision-list">
+            {l.interview.followUps.map((question) => (
+              <li key={question}>{question}</li>
+            ))}
+          </ul>
+        </section>
+      )}
       <section id="exercise" className="exercise">
         <div className="section-heading">
-          <h2>扩展练习</h2>
+          <h2>{theory ? '自己设计一次' : '自己试一次'}</h2>
           <span>完成后再核对答案</span>
         </div>
         <p>{l.exercise.task}</p>
@@ -962,10 +1258,32 @@ function LessonPage({
           <div className="answer-text">{l.exercise.answer}</div>
         </details>
       </section>
+      <section id="concepts" className="lesson-recap">
+        <div className="section-heading">
+          <h2>核心概念回顾</h2>
+          <span>把刚才的操作与现象整理成知识</span>
+        </div>
+        <p className="learning-section-note">
+          {theory
+            ? '回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。'
+            : '回想刚才在哪一步用到了它、看到了什么结果。能结合自己的操作解释清楚,才算理解了这个概念。'}
+        </p>
+        {l.concepts.map((c, i) => (
+          <div className="concept" key={c.title}>
+            <h3>
+              <span>{i + 1}.</span>
+              {c.title}
+            </h3>
+            <p>{c.body}</p>
+          </div>
+        ))}
+      </section>
       <section id="references">
         <div className="section-heading">
-          <h2>技术文档</h2>
-          <span>原始定义与框架用法</span>
+          <h2>{theory ? '延伸阅读' : '技术文档'}</h2>
+          <span>
+            {theory ? '原始论文与官方设计资料' : '原始定义与框架用法'}
+          </span>
         </div>
         <div className="reference-links">
           {l.refs.map((r) => (
@@ -976,6 +1294,21 @@ function LessonPage({
           ))}
         </div>
       </section>
+      {index === lessons.length - 1 && (
+        <div className="overview-actions">
+          <LocalLink
+            module="review"
+            lesson={String(
+              modules.findIndex((item) => item.slug === m.slug) + 1,
+            ).padStart(2, '0')}
+            navigate={navigate}
+            className="secondary-link"
+          >
+            学完本模块,去复盘与自测
+            <ArrowRight size={16} />
+          </LocalLink>
+        </div>
+      )}
       <div className="lesson-pagination">
         {index > 0 ? (
           <LocalLink

+ 7 - 7
app/demos.tsx

@@ -186,9 +186,9 @@ export function ProtocolDemo() {
 const requestParts = [
   {
     label: '请求行',
-    code: 'POST /notes HTTP/1.1',
+    code: 'POST /products HTTP/1.1',
     description:
-      'POST 是请求方法,/notes 是资源路径,HTTP/1.1 是协议版本。方法与路径共同决定匹配哪个路由。',
+      'POST 是请求方法,/products 是资源路径,HTTP/1.1 是协议版本。方法与路径共同决定匹配哪个路由。',
   },
   {
     label: '请求头',
@@ -198,7 +198,7 @@ const requestParts = [
   },
   {
     label: '请求体',
-    code: '{"title":"第一条笔记","content":"hello","pinned":false}',
+    code: '{"name":"帆布袋","description":"轻便日用","price_cents":1990,"is_active":false}',
     description:
       'JSON 是传输格式。FastAPI 读取正文后,由 Pydantic 按字段类型和约束进行校验,再把数据交给函数。',
   },
@@ -227,7 +227,7 @@ export function HttpDemo() {
       <pre className="packet">
         <code>
           {invalid && part === 2
-            ? '{"content":"hello","pinned":false}'
+            ? '{"description":"轻便日用","price_cents":1990,"is_active":false}'
             : requestParts[part].code}
         </code>
       </pre>
@@ -250,7 +250,7 @@ export function HttpDemo() {
       >
         <TabsList>
           <TabsTrigger value="valid">合法请求</TabsTrigger>
-          <TabsTrigger value="invalid">缺少 title</TabsTrigger>
+          <TabsTrigger value="invalid">缺少 name</TabsTrigger>
         </TabsList>
       </Tabs>
       <div className="demo-result" aria-live="polite">
@@ -258,9 +258,9 @@ export function HttpDemo() {
           {stage < 0
             ? '单步查看服务端如何处理这个请求。'
             : invalid && stage === 1
-              ? '422 Unprocessable Entity:缺少 title;路由函数没有执行。'
+              ? '422 Unprocessable Entity:缺少 name;路由函数没有执行。'
               : stage === 3
-                ? '201 Created:返回新笔记的 JSON,响应中包含 id。'
+                ? '201 Created:返回新商品的 JSON,响应中包含 id。'
                 : `当前:${steps[stage]}。继续查看下一步。`}
         </p>
       </div>

+ 1529 - 0
app/diagram-catalog.ts

@@ -0,0 +1,1529 @@
+export type DiagramTone = 'blue' | 'green' | 'amber' | 'red' | 'neutral';
+export type DiagramPoint = [number, number];
+export type DiagramNode = {
+  id: string;
+  title: string;
+  detail: string;
+  x: number;
+  y: number;
+  tone: DiagramTone;
+};
+export type DiagramEdge = {
+  from: string;
+  to: string;
+  label: string;
+  kind: 'sync' | 'async' | 'risk';
+  fromPort?: 'left' | 'right' | 'top' | 'bottom';
+  toPort?: 'left' | 'right' | 'top' | 'bottom';
+  via?: DiagramPoint[];
+  labelAt?: DiagramPoint;
+};
+export type TeachingDiagram = {
+  id: string;
+  module: string;
+  lessons: string[];
+  overview?: boolean;
+  title: string;
+  summary: string;
+  rows: string[];
+  nodes: DiagramNode[];
+  edges: DiagramEdge[];
+  steps: string[];
+  misconception: string;
+  source?: { label: string; url: string };
+};
+
+const n = (
+  id: string,
+  title: string,
+  detail: string,
+  column: number,
+  row: number,
+  tone: DiagramTone = 'blue',
+): DiagramNode => ({
+  id,
+  title,
+  detail,
+  x: 30 + column * 315,
+  y: 65 + row * 185,
+  tone,
+});
+const e = (
+  from: string,
+  to: string,
+  label: string,
+  kind: DiagramEdge['kind'] = 'sync',
+  options: Partial<Omit<DiagramEdge, 'from' | 'to' | 'label' | 'kind'>> = {},
+): DiagramEdge => ({ from, to, label, kind, ...options });
+
+export const teachingDiagrams: TeachingDiagram[] = [
+  {
+    id: 'product-schema',
+    module: 'database',
+    lessons: ['02', '04'],
+    overview: true,
+    title: '从商品与分类需求,推导三张表',
+    summary:
+      '一件商品可以属于多个分类,一个分类也可以包含多件商品。把商品、分类和归属关系分开保存,让数据库保护关系和字段规则。',
+    rows: [
+      '实体与关系:两个一对多,表达一个多对多',
+      '设计依据:字段规则与访问路径',
+    ],
+    nodes: [
+      n('products', 'products · 商品', '主键 id;名称、描述、价格', 0, 0),
+      n(
+        'links',
+        'product_categories',
+        '联合主键:商品 ID + 分类 ID',
+        1,
+        0,
+        'green',
+      ),
+      n('categories', 'categories · 分类', '主键 id;唯一分类名', 2, 0),
+      n(
+        'fields',
+        '字段类型与约束',
+        '价格按分存;非空与范围检查',
+        0,
+        1,
+        'amber',
+      ),
+      n(
+        'foreign-keys',
+        '外键与删除策略',
+        '关联必须指向已有实体',
+        1,
+        1,
+        'amber',
+      ),
+      n('queries', '按查询设计索引', '按分类找商品;排序与分页', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('products', 'links', '1 → 多'),
+      e('categories', 'links', '1 → 多', 'sync', {
+        fromPort: 'left',
+        toPort: 'right',
+      }),
+      e('products', 'fields', '字段设计'),
+      e('links', 'foreign-keys', '关系约束'),
+      e('categories', 'queries', '查询需求'),
+    ],
+    steps: [
+      '先列需要支持的操作:商品详情、上下架、列表分页、分类筛选。由此确定商品与分类实体;同名商品可以存在,分类名在本练习中唯一。',
+      '商品价格 price_cents 使用非负 INT,单位是分;名称用有长度上限的 VARCHAR,描述用 TEXT,上架状态默认 false,时间按 UTC 写入 DATETIME(6)。',
+      '关联表用 (product_id, category_id) 联合主键避免重复归类,再用两个外键禁止孤立关系;按商品和按分类读取需要检查不同索引方向。',
+      '第 02 课先完成商品表,第 04 课补齐分类与关联表。用非法价格、重复关联、无效外键和删除实验验证设计,而不是只看建表成功。',
+    ],
+    misconception:
+      '把分类名用逗号拼进商品字段,会让去重、筛选和改名变难;把所有可能用到的字段提前塞进表里,也会增加无关的业务复杂度。',
+  },
+  {
+    id: 'first-health-request',
+    module: 'api',
+    lessons: ['01'],
+    title: '回看刚才那次 /health 访问',
+    summary:
+      '你已经在浏览器看到了 ok。现在沿着刚才写过的 main.py 和终端里的运行记录,把这次访问连起来。',
+    rows: ['浏览器把请求交给正在运行的程序', '函数的返回值回到浏览器'],
+    nodes: [
+      n('browser', '浏览器访问地址', '127.0.0.1:8000/health', 0, 0),
+      n('server', 'Uvicorn 正在运行', '在本机 8000 端口接收请求', 1, 0),
+      n('route', 'FastAPI 找到路由', '@app.get("/health")', 2, 0),
+      n('function', '执行 health 函数', '返回 {"status": "ok"} 字典', 2, 1),
+      n('response', '框架转换返回值', '200 状态与 JSON 文本', 1, 1, 'green'),
+      n('screen', '浏览器展示结果', '页面上出现 status 和 ok', 0, 1, 'green'),
+    ],
+    edges: [
+      e('browser', 'server', '① 发出请求'),
+      e('server', 'route', '② 交给应用'),
+      e('route', 'function', '③ 找到函数'),
+      e('function', 'response', '④ 转成文本'),
+      e('response', 'screen', '⑤ 返回结果'),
+    ],
+    steps: [
+      '启动命令使 Uvicorn 留在终端中接收请求;仅保存 main.py 文件还没有启动这个过程。',
+      '浏览器访问 /health,FastAPI 根据你写的 @app.get("/health") 找到 health 函数,再调用它。',
+      'health 返回的是 Python 字典。框架把它转换成 JSON 文本并附上 200 状态,浏览器才有可以展示的结果。修改 ok 为 ready 后,你看到的就是这条返回路径上的变化。',
+    ],
+    misconception:
+      '直接访问 / 得到 404,是因为这一课只注册了 /health;按 Ctrl+C 停止服务后无法连接,则是整个接收请求的进程已经停止。两种现象对应不同问题。',
+  },
+  {
+    id: 'http-roundtrip',
+    module: 'api',
+    lessons: ['02', '03', '04', '05', '06', '07', '08', '16'],
+    overview: true,
+    title: '一次请求怎样找到你的 Python 函数',
+    summary:
+      '把 HTTP 想成带地址的信件:端口找到服务,方法和路径找到函数,参数校验决定能否进入业务处理。',
+    rows: [
+      '去程:调用者等待这一份响应',
+      '回程:状态码说明结果,响应体携带数据',
+    ],
+    nodes: [
+      n('browser', '浏览器 / 调用方', 'GET /products/42', 0, 0),
+      n('server', 'Uvicorn 服务', '监听地址与端口', 1, 0),
+      n('router', 'FastAPI 路由', '匹配方法、路径并校验', 2, 0),
+      n('handler', '业务函数', '查找商品或生成错误', 2, 1),
+      n('response', 'HTTP 响应', '状态码 + JSON 数据', 1, 1, 'green'),
+      n('screen', '调用方处理结果', '成功展示;失败给出提示', 0, 1),
+    ],
+    edges: [
+      e('browser', 'server', '① 发请求'),
+      e('server', 'router', '② 转交'),
+      e('router', 'handler', '③ 调用'),
+      e('handler', 'response', '④ 序列化'),
+      e('response', 'screen', '⑤ 返回'),
+    ],
+    steps: [
+      '调用方连接指定的主机和端口;服务进程必须先启动并监听,函数本身不会自动接收网络请求。',
+      'FastAPI 按 HTTP 方法和路径选择路由,再把路径、查询串和 JSON 解析成声明的参数;校验失败会提前返回错误。',
+      '业务函数执行后,框架将结果转换成 HTTP 响应。调用方同时检查状态码与响应体,才知道该显示数据还是错误。',
+    ],
+    misconception:
+      '浏览器地址栏通常发 GET。写了 POST 路由后,只在地址栏输入同一路径,并不等于调用了这个 POST 接口。',
+  },
+  {
+    id: 'await-handoff',
+    module: 'api',
+    lessons: ['09', '10'],
+    title: 'await 让出执行机会,请求依然在等待',
+    summary:
+      '在单个事件循环线程中,等待网络的协程可以暂停,让别的协程运行;这与把工作交给独立 worker 是两件事。',
+    rows: [
+      '请求 A:从发起调用到收到结果,HTTP 请求仍未完成',
+      '等待期间:同一个事件循环可以推进其他请求',
+    ],
+    nodes: [
+      n('a', '请求 A 开始', '准备查询商品数据', 0, 0),
+      n('io', '等待外部 I/O', 'await 可等待的网络操作', 1, 0, 'amber'),
+      n('resume', '请求 A 恢复', '收到结果后继续并响应', 2, 0, 'green'),
+      n('loop', '事件循环调度', 'A 暂停期间选择就绪任务', 1, 1),
+      n('b', '请求 B 执行', '例如处理一个健康检查', 2, 1),
+    ],
+    edges: [
+      e('a', 'io', '① 发起 I/O'),
+      e('io', 'resume', '③ I/O 就绪'),
+      e('io', 'loop', '② 让出执行'),
+      e('loop', 'b', '处理就绪任务'),
+    ],
+    steps: [
+      '请求 A 发起支持异步的网络操作。在结果尚未就绪时,await 可以暂停 A,调用方还在等 A 的响应。',
+      '事件循环利用这段等待时间处理请求 B。一个事件循环线程同一时刻仍只执行一段 Python 代码。',
+      'I/O 就绪后,A 再获得执行机会并返回结果。CPU 密集循环或直接调用阻塞函数仍会占住线程,需要单独选择线程池或进程池。',
+    ],
+    misconception:
+      'async def 不会自动把阻塞代码变成异步;await 也不保证每次都发生任务切换。await 更不会自动创建持久化后台任务。',
+    source: {
+      label: 'Python:协程与任务',
+      url: 'https://docs.python.org/3/library/asyncio-task.html',
+    },
+  },
+  {
+    id: 'stream-lifetime',
+    module: 'api',
+    lessons: ['11', '12', '13', '14', '15'],
+    title: '流式输出是一条持续占用资源的连接',
+    summary:
+      'SSE 把一次响应分成多段送回。页面停止接收后,需要沿调用链清理资源,不能只让文字停止显示。',
+    rows: [
+      '数据方向:导出进度产生,服务逐段转发',
+      '停止方向:断开被感知后,逐层取消与清理',
+    ],
+    nodes: [
+      n('model', '进度 / 数据源', '片段 1、片段 2、片段 3', 0, 0),
+      n('api', 'API 流生成器', '编码事件并转发', 1, 0),
+      n('browser', '浏览器读流', '逐段解析、更新页面', 2, 0),
+      n('stop', '用户停止 / 断网', '停止读取或断开连接', 2, 1, 'amber'),
+      n('cleanup', 'API 清理资源', '感知断开;finally 释放', 1, 1, 'green'),
+      n('upstream', '停止上游工作', '取消可取消调用并关连接', 0, 1),
+    ],
+    edges: [
+      e('model', 'api', '① 数据片段'),
+      e('api', 'browser', '② SSE 事件'),
+      e('browser', 'stop', '③ 中止'),
+      e('stop', 'cleanup', '④ 传播取消', 'risk'),
+      e('cleanup', 'upstream', '⑤ 释放资源'),
+    ],
+    steps: [
+      '完整响应要等结果准备完再返回;SSE 可以先发送响应头,随后持续发送事件,因此用户能更早看到内容。',
+      '流经过 API、代理和浏览器。任意一层缓冲都可能让“已经生成”的片段迟迟无法显示;长连接还需要超时和心跳策略。',
+      '浏览器断开后,服务应处理取消并关闭上游连接。独立队列中的任务不会因为 HTTP 连接断开而自动取消,需要另设取消协议。',
+    ],
+    misconception:
+      'SSE 是服务端向客户端发送事件;WebSocket 提供双向消息。两者都不会自动补回断线期间丢失的业务消息。',
+  },
+  {
+    id: 'transaction-boundary',
+    module: 'database',
+    lessons: ['01', '02', '03', '04', '05', '06', '11', '12'],
+    overview: true,
+    title: '事务把多次写入变成一个提交决定',
+    summary:
+      '以创建商品及其分类关系为例:数据库应当一起保存相关变化,避免出现只有一半完成的业务状态。',
+    rows: ['同一连接、同一个事务内', '结束事务后:要么整体提交,要么整体回滚'],
+    nodes: [
+      n('begin', 'BEGIN 开始', '应用取得一条数据库连接', 0, 0),
+      n('note', '写入商品', '暂未提交的变更', 1, 0, 'amber'),
+      n('tag', '写入分类关系', '约束检查可能失败', 2, 0, 'amber'),
+      n('decision', '应用决定结束', '根据执行结果选择路径', 2, 1),
+      n('commit', 'COMMIT 提交', '两份数据一起生效', 1, 1, 'green'),
+      n('rollback', 'ROLLBACK 回滚', '撤销本事务未提交变更', 0, 1, 'red'),
+    ],
+    edges: [
+      e('begin', 'note', '① 执行'),
+      e('note', 'tag', '② 执行'),
+      e('tag', 'decision', '③ 判断'),
+      e('decision', 'commit', '成功'),
+      e('decision', 'rollback', '失败', 'risk', {
+        fromPort: 'bottom',
+        toPort: 'bottom',
+        via: [
+          [765, 388],
+          [135, 388],
+        ],
+        labelAt: [450, 383],
+      }),
+    ],
+    steps: [
+      '应用在同一数据库连接上开启事务,依次执行相互关联的写入。唯一约束和外键约束由数据库统一检查。',
+      '全部满足业务条件时执行 COMMIT;失败时显式回滚并清理事务,避免把带有未提交修改的连接交给下一次请求。',
+      '图中是 InnoDB 数据修改事务。DDL 可能有隐式提交,外部 HTTP 调用、发邮件和发消息也不会被这个本地事务自动回滚。',
+    ],
+    misconception:
+      '“放在同一段函数里”不等于“在同一事务里”。读者何时能看见变化还取决于事务隔离级别和读取方式。',
+  },
+  {
+    id: 'lock-wait',
+    module: 'database',
+    lessons: ['06', '07', '08', '09', '10'],
+    title: '两个请求改同一行,锁把竞争排成等待',
+    summary:
+      '锁保护共享数据,但持锁时间越长,后面请求越容易排队。等待数据库锁的连接仍然被占用。',
+    rows: [
+      '事务 A:先取得行锁,并尽快提交',
+      '事务 B:竞争同一行,需要等待或超时',
+    ],
+    nodes: [
+      n('a', '事务 A 更新', 'UPDATE 商品 42', 0, 0),
+      n('row', '商品 42 的行锁', 'A 持有,直至事务结束', 1, 0, 'amber'),
+      n('done', 'A 提交并释放', '临界区应当短小', 2, 0, 'green'),
+      n('b', '事务 B 更新', '也要修改商品 42', 0, 1),
+      n('wait', 'B 等待锁', '连接占用;有等待上限', 1, 1, 'amber'),
+      n('retry', 'B 获锁后继续', '超时 / 死锁则回滚处理', 2, 1),
+    ],
+    edges: [
+      e('a', 'row', '① 取得锁'),
+      e('row', 'done', '③ 提交'),
+      e('b', 'wait', '② 锁冲突'),
+      e('wait', 'retry', '⑤ 继续 / 失败'),
+      e('done', 'retry', '④ 释放后唤醒'),
+    ],
+    steps: [
+      'A 对目标行执行需要加锁的操作。B 修改同一行时遇到冲突,必须等待 A 提交或回滚。',
+      '如果 A 在事务中等待慢外部调用,锁和连接也会跟着长时间占用,造成更多排队。因此外部慢调用应尽量离开持锁区。',
+      '多行锁获取顺序不一致可能造成死锁。数据库会选择牺牲事务,应用需要回滚并按有界重试策略重做完整业务事务。',
+    ],
+    misconception:
+      '锁不一定只覆盖你看到的一行。访问路径、索引和隔离级别会影响加锁范围;增加索引也不能代替事务边界设计。',
+  },
+  {
+    id: 'session-pool',
+    module: 'api-database',
+    lessons: ['01', '02', '03', '04', '05', '06', '08', '12'],
+    overview: true,
+    title: 'Session 属于请求,连接来自有上限的池',
+    summary:
+      'Session 像本次办事的工作单,连接池像有限数量的窗口。工作单不该跨并发请求共享,窗口也不能无限增加。',
+    rows: [
+      '请求生命周期:独立 Session → 借连接 → 访问数据库',
+      '请求结束:提交或回滚,再清理并归还连接',
+    ],
+    nodes: [
+      n('request', '请求 A', '创建本次独立 Session', 0, 0),
+      n('pool', '当前进程连接池', '没有空闲连接时有界等待', 1, 0, 'amber'),
+      n('mysql', 'MySQL', '执行查询与事务', 2, 0, 'green'),
+      n('end', '提交 / 回滚', '明确业务结束边界', 2, 1),
+      n('release', '连接归还池', '清理会话与事务状态', 1, 1, 'green'),
+      n('next', '后续请求 B', '使用自己的 Session', 0, 1),
+    ],
+    edges: [
+      e('request', 'pool', '① 按需借用'),
+      e('pool', 'mysql', '② 执行 SQL'),
+      e('mysql', 'end', '③ 结束'),
+      e('end', 'release', '④ 释放'),
+      e('release', 'next', '⑤ 可供借用'),
+    ],
+    steps: [
+      '每个请求建立独立 Session;实际执行 SQL 时按需从连接池取得连接。并发协程也不应共享可变的 AsyncSession。',
+      '连接池满时,后续请求排队到超时。多个 API 进程、worker 进程和发布期间的新旧副本,都可能各自拥有一份连接池。',
+      '总预算可按“进程数 × 每进程连接上限”估算,再给运维和后台作业留余量。结束请求时确保提交或回滚,并在所有退出路径释放资源。',
+    ],
+    misconception:
+      '把每个进程的池扩大到 100,不代表系统只会占用 100 条连接。扩副本也会放大数据库的连接和查询压力。',
+  },
+  {
+    id: 'optimistic-update',
+    module: 'api-database',
+    lessons: ['09', '10'],
+    title: '版本号让数据库识别“我改的是旧版本”',
+    summary:
+      '两个人都读到第 7 版时,只允许第一份带 version=7 的修改成功,另一份必须重新决定如何处理冲突。',
+    rows: [
+      '两个请求看到相同旧值:商品 version = 7',
+      '写入时由数据库原子检查条件,不能只在应用里先判断',
+    ],
+    nodes: [
+      n('a', '编辑请求 A', '提交:旧版本 7 + 新内容', 0, 0),
+      n('db', '数据库条件更新', 'WHERE id=42 AND version=7', 1, 0, 'green'),
+      n('b', '编辑请求 B', '也提交:旧版本 7', 2, 0),
+      n('ok', 'A 更新成功', '影响 1 行;版本变为 8', 0, 1, 'green'),
+      n('new', '当前真值:版本 8', '只有 A 的修改已提交', 1, 1, 'green'),
+      n('conflict', 'B 发现冲突', '影响 0 行;重新读取处理', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('a', 'db', '① 先到'),
+      e('b', 'db', '② 后到'),
+      e('a', 'ok', '条件成立'),
+      e('db', 'new', '提交 version+1'),
+      e('b', 'conflict', '条件失效'),
+    ],
+    steps: [
+      '读取商品时同时拿到 version,编辑时带回这个版本。数据库在同一条 UPDATE 中检查版本并递增版本号。',
+      'A 先成功把版本从 7 改为 8。B 的 version=7 条件不再匹配,应用通过影响行数判断冲突。',
+      '业务可以返回冲突让用户比较、合并,或重新计算并有界重试。幂等键另外解决“同一意图重复提交”,与版本号解决的问题不同。',
+    ],
+    misconception:
+      '先 SELECT 检查版本,再执行没有版本条件的 UPDATE,中间仍然有竞争窗口;检查与写入必须由数据库原子完成。',
+  },
+  {
+    id: 'compatible-migration',
+    module: 'api-database',
+    lessons: ['07'],
+    title: '兼容迁移:新旧应用共存时,数据库要同时接得住',
+    summary:
+      '滚动发布会让新旧版本同时运行。先扩展兼容结构,再迁移应用和数据,最后清理旧结构,可以保留明确的回退窗口。',
+    rows: [
+      '扩展阶段:先增加兼容结构,再发布能共存的应用',
+      '收缩阶段:数据校验和旧版本退出完成以后,才清理旧结构',
+    ],
+    nodes: [
+      n('expand', '扩展数据库结构', '例如先新增可空列', 0, 0, 'green'),
+      n('coexist', '新旧应用同时运行', '都能读取和写入当前结构', 1, 0),
+      n('backfill', '分批回填旧数据', '有进度、限速与校验', 2, 0, 'amber'),
+      n('contract', '清理旧结构', '明确回退窗口已发生变化', 0, 1, 'amber'),
+      n('retire', '确认旧版本退出', '包括后台进程与定时任务', 1, 1),
+      n('verify', '校验并切换读取', '确认新字段完整且语义正确', 2, 1, 'green'),
+    ],
+    edges: [
+      e('expand', 'coexist', '① 兼容扩展'),
+      e('coexist', 'backfill', '② 分批迁移', 'async'),
+      e('backfill', 'verify', '③ 校验'),
+      e('verify', 'retire', '④ 结束共存'),
+      e('retire', 'contract', '⑤ 谨慎收缩'),
+    ],
+    steps: [
+      '先做旧应用仍能使用的结构扩展,例如新增可空字段。迁移任务独立执行,避免每个 API 副本启动时争抢执行 DDL。',
+      '发布支持新结构的应用,按业务语义处理新旧字段共存,并分批回填历史数据;回填需要限速、断点与结果校验。',
+      '确认旧应用和后台进程都不再使用旧结构,再做收缩清理。数据库 DDL 有自身提交与锁行为,代码版本回退不能自动撤销这些变化。',
+    ],
+    misconception:
+      '“迁移脚本有 downgrade”不代表线上可以无损回滚。删除字段或不可逆数据转换后,恢复可能必须依赖备份、对账或新的前向修复。',
+  },
+  {
+    id: 'outbox-delivery',
+    module: 'api-database',
+    lessons: ['11'],
+    title: 'Outbox:先把“需要发送”与业务数据一起记下来',
+    summary:
+      '数据库提交和消息发布属于两个系统,无法靠连续写两行代码保证一起成功。Outbox 把可恢复的发送意图保存在数据库。',
+    rows: [
+      '本地事务边界:业务记录 + 待发送事件一起提交',
+      '事务外:发布进程持续重试,消费者处理重复事件',
+    ],
+    nodes: [
+      n('api', 'API 接收请求', '创建业务记录与事件', 0, 0),
+      n('db', 'MySQL 本地事务', '业务表 + outbox 表', 1, 0, 'green'),
+      n('relay', 'Outbox 发布进程', '扫描未完成事件', 2, 0),
+      n('broker', '消息 Broker', '接收事件,交给消费者', 2, 1, 'amber'),
+      n('worker', '消费者 / Worker', '按事件 ID 幂等处理', 1, 1),
+      n('result', '业务结果落库', '状态可查询,失败可恢复', 0, 1, 'green'),
+    ],
+    edges: [
+      e('api', 'db', '① 原子提交'),
+      e('db', 'relay', '② 读取意图', 'async'),
+      e('relay', 'broker', '③ 发布 / 重试', 'async'),
+      e('broker', 'worker', '④ 可重复投递', 'async'),
+      e('worker', 'result', '⑤ 持久化结果'),
+    ],
+    steps: [
+      'API 在一个 MySQL 事务中写业务记录和 outbox 事件;提交后即使 API 进程退出,发送意图仍可被后续进程发现。',
+      '独立发布进程扫描事件并发送到 Broker。发布失败可以重试;发送成功但标记完成前崩溃,则可能再次发送。',
+      '消费者用稳定事件 ID 和数据库约束保护幂等副作用。Outbox 解决可靠交接意图,投递耐久性仍取决于 Broker 配置、确认和恢复策略。',
+    ],
+    misconception:
+      'Outbox 不能保证全链路“恰好执行一次”,也不会让下游立刻可见。业务需要接受延迟,并处理重复、积压和失败。',
+  },
+  {
+    id: 'task-lifetimes',
+    module: 'tasks',
+    lessons: ['01', '03', '07'],
+    overview: true,
+    title: '同样叫“后台”,重启后的命运却不同',
+    summary:
+      '是否先返回 HTTP 响应,与工作能否在进程崩溃后恢复,是两个独立问题。',
+    rows: [
+      '进程内后台:工作依赖当前 API 进程继续存活',
+      '独立任务系统:状态、交接和执行都有明确责任',
+    ],
+    nodes: [
+      n('request', 'HTTP 请求', '提交商品清单导出工作', 0, 0),
+      n(
+        'local',
+        'API 进程内任务',
+        'BackgroundTasks / create_task',
+        1,
+        0,
+        'amber',
+      ),
+      n('lost', 'API 进程退出', '未完成工作可能丢失', 2, 0, 'red'),
+      n('state', '持久任务记录', 'job_id + 状态 + outbox', 0, 1, 'green'),
+      n('queue', '独立 Broker', '排队并按策略投递', 1, 1, 'amber'),
+      n('worker', '独立 Worker', '执行、落库、确认;可恢复', 2, 1),
+    ],
+    edges: [
+      e('request', 'local', '进程内方案'),
+      e('local', 'lost', '重启 / 崩溃', 'risk'),
+      e('request', 'state', '可靠方案'),
+      e('state', 'queue', '异步发布', 'async'),
+      e('queue', 'worker', '异步交接', 'async'),
+    ],
+    steps: [
+      '进程内后台任务方便完成较小且可丢失的辅助工作;BackgroundTasks 在响应之后执行,create_task 调度协程,两者都依赖当前进程。',
+      '需要恢复的任务先保存任务状态和发送意图,再由独立 Broker 与 worker 协作。API 返回 job_id,客户端通过查询接口获取进度。',
+      '202 仅表示已受理。只有任务状态到达 succeeded,且结果引用可用,才能认为处理完成;早期内存实验不提供持久交付承诺。',
+    ],
+    misconception:
+      '换成 Celery 并不自动可靠:任务状态、发布失败、重复执行、Broker 数据丢失和 worker 退出,都要分别设计。',
+  },
+  {
+    id: 'execution-choice',
+    module: 'tasks',
+    lessons: ['02', '04', '05', '06', '07'],
+    title: '等待 I/O、阻塞调用、CPU 运算分别交给谁',
+    summary:
+      '先判断瓶颈,再选择执行方式。并发数量必须受控,否则只是把排队挪到数据库、线程池或内存里。',
+    rows: ['工作特征决定执行位置', '三种执行方式都需要容量、超时和清理边界'],
+    nodes: [
+      n('asyncio', '支持异步的 I/O', 'HTTP、数据库的异步驱动', 0, 0),
+      n('blocking', '同步阻塞 I/O', '只能同步调用的旧 SDK', 1, 0),
+      n('cpu', 'CPU 密集计算', '压缩、解析、大量纯计算', 2, 0),
+      n('coroutine', '有界协程', 'await + Semaphore / Queue', 0, 1, 'green'),
+      n('thread', '有界线程池', '把阻塞调用移出事件循环', 1, 1, 'green'),
+      n('process', '有界进程池', '独立内存;序列化传参', 2, 1, 'green'),
+    ],
+    edges: [
+      e('asyncio', 'coroutine', '等待时让出'),
+      e('blocking', 'thread', '线程代为等待'),
+      e('cpu', 'process', '进程承担计算'),
+    ],
+    steps: [
+      '驱动支持异步时,协程等待 I/O 可以让出事件循环;用有界队列和并发上限避免同时发出成千上万次下游调用。',
+      '必须使用同步阻塞 SDK 时,可把调用放入有界线程池。线程共享进程内存,复合读改写仍可能需要锁;超时等待不等于线程已停止。',
+      '本课程常见 CPython 配置中的 CPU 密集 Python 运算适合进程池;进程间传递可序列化数据。需要跨重启恢复时,再选择独立任务队列。',
+    ],
+    misconception:
+      '线程池、进程池不是持久化队列。增加并发不保证更快,也可能让下游限流、连接耗尽或内存先被打满。',
+  },
+  {
+    id: 'ack-redelivery',
+    module: 'tasks',
+    lessons: ['08', '09', '10', '12', '14', '15'],
+    title: '结果已写入,但 ack 丢了:为什么会重复执行',
+    summary:
+      'ack 是消费者告诉 Broker“这一份可以结束投递”的确认,它与业务结果写入通常不是同一个原子动作。',
+    rows: [
+      '第一次投递:业务已成功,但消费确认未被 Broker 收到',
+      '恢复路径:可能重投同一任务,消费者复用已完成结果',
+    ],
+    nodes: [
+      n('broker', 'Broker 中的任务', '稳定 task_id / 业务键', 0, 0, 'amber'),
+      n('worker', 'Worker A', '执行并提交业务结果', 1, 0),
+      n('result', 'MySQL / 结果文件', '结果已持久化成功', 2, 0, 'green'),
+      n(
+        'redeliver',
+        '未确认任务恢复',
+        '按后端与配置决定何时重投',
+        0,
+        1,
+        'amber',
+      ),
+      n('again', 'Worker B 再收到', '先检查同一任务的结果', 1, 1),
+      n('reuse', '复用结果并确认', '不重复产生业务副作用', 2, 1, 'green'),
+    ],
+    edges: [
+      e('broker', 'worker', '① 投递', 'async'),
+      e('worker', 'result', '② 写结果'),
+      e('worker', 'redeliver', '③ ack 前崩溃', 'risk', {
+        fromPort: 'bottom',
+        toPort: 'top',
+        via: [
+          [450, 193],
+          [135, 193],
+        ],
+        labelAt: [288, 188],
+      }),
+      e('redeliver', 'again', '④ 可能重投', 'async'),
+      e('again', 'reuse', '⑤ 幂等处理'),
+    ],
+    steps: [
+      '在成功后确认的设计里,worker 先持久化业务结果,再确认任务完成。发布方确认和消费者 ack 是不同阶段的确认。',
+      'worker 在结果提交后、ack 生效前退出,Broker 仍可能认为这份任务未完成。具体恢复时间受 broker、可见性超时和 Celery 配置影响。',
+      '重投消费者通过稳定任务键、唯一约束和可复用输出识别已完成工作,再安全确认。外部付款、发信等副作用也需要对应服务支持幂等或对账。',
+    ],
+    misconception:
+      '提前 ack 会缩小重复窗口,却可能在执行前崩溃时丢工作;延后 ack 也不自动提供“恰好一次”。可靠配置与幂等必须一起考虑。',
+    source: {
+      label: 'RabbitMQ:确认与可靠投递原理',
+      url: 'https://www.rabbitmq.com/docs/reliability',
+    },
+  },
+  {
+    id: 'task-state-source',
+    module: 'tasks',
+    lessons: ['11', '12', '15', '16'],
+    title: '队列负责送任务,数据库负责回答任务发生了什么',
+    summary:
+      '消息出队不等于业务完成。任务状态、执行租约和结果引用需要一个可查询、可校验的数据真源。',
+    rows: [
+      '执行路径:worker 先取得当前执行权,再提交结果',
+      '查询与恢复:客户端和恢复扫描器都读取持久状态',
+    ],
+    nodes: [
+      n('queue', 'Broker', '投递 job_id', 0, 0, 'amber'),
+      n('worker', 'Worker', '申请租约;携带执行令牌', 1, 0),
+      n('db', 'MySQL 任务记录', '状态 + 租约 + 结果引用', 2, 0, 'green'),
+      n('client', '客户端查状态', 'GET /exports/{job_id}', 0, 1),
+      n('api', '查询 API', '读业务真源并返回', 1, 1),
+      n('recovery', '恢复扫描器', '扫描过期租约并按规则恢复', 2, 1),
+    ],
+    edges: [
+      e('queue', 'worker', '① 消息交接', 'async'),
+      e('worker', 'db', '② 条件写状态'),
+      e('client', 'api', '③ 查进度'),
+      e('api', 'db', '读取真值', 'sync', {
+        fromPort: 'right',
+        toPort: 'bottom',
+        via: [
+          [607, 289],
+          [607, 202],
+          [765, 202],
+        ],
+        labelAt: [690, 196],
+      }),
+      e('db', 'recovery', '④ 扫描与校验', 'async'),
+    ],
+    steps: [
+      'worker 按条件从 queued 取得执行租约,进入 running。更新成功或失败状态时带上当前执行令牌,拒绝已经失去执行权的旧 worker。',
+      '结果文件放入可访问的持久存储,MySQL 记录结果引用;查询 API 以数据库状态为准,不能把队列长度或短期结果缓存当业务真相。',
+      'worker 宕机后,恢复扫描器识别过期租约,按幂等规则决定重排或失败。取消请求也应持久化,并由执行者在安全点处理。',
+    ],
+    misconception:
+      '任务超过租约时间,不代表旧进程已经停止。新的执行者开始后,旧执行者仍可能返回,必须有防止旧结果覆盖的新旧执行权校验。',
+  },
+  {
+    id: 'backpressure',
+    module: 'tasks',
+    lessons: ['02', '10', '13', '16'],
+    title: '队列像等候区:只能缓冲,不能凭空增加处理能力',
+    summary:
+      '当每秒新来 100 件工作,却只能完成 60 件时,积压会持续增加。入口、排队与下游都需要容量边界。',
+    rows: [
+      '受理之前:根据容量决定接收、限流或让调用方稍后再试',
+      '受理之后:分队列隔离,并发受下游承载能力约束',
+    ],
+    nodes: [
+      n('input', '请求入口', '持续到达:100 件 / 秒', 0, 0),
+      n('gate', '受理门槛', '租户配额 + 总积压上限', 1, 0),
+      n('reject', '超出容量', '429 / 503 + 合理重试', 2, 0, 'red'),
+      n('short', '短任务队列', '独立容量,避免被长任务挡住', 0, 1, 'amber'),
+      n('long', '长任务队列', '有界积压,记录最老等待时间', 1, 1, 'amber'),
+      n('workers', 'Worker + 下游', '当前最多完成:60 件 / 秒', 2, 1, 'green'),
+    ],
+    edges: [
+      e('input', 'gate', '① 评估容量'),
+      e('gate', 'reject', '已满则拒绝', 'risk'),
+      e('gate', 'short', '② 按类型交接', 'async'),
+      e('gate', 'long', '② 长任务', 'async'),
+      e('short', 'workers', '③ 独立消费', 'async', {
+        fromPort: 'bottom',
+        toPort: 'bottom',
+        via: [
+          [135, 388],
+          [765, 388],
+        ],
+        labelAt: [450, 383],
+      }),
+      e('long', 'workers', '③ 有界消费', 'async'),
+    ],
+    steps: [
+      '如果到达率长期高于完成率,每秒多出的 40 件会累积。先在受理前设置容量门槛,明确拒绝语义和调用方的重试预算。',
+      '将长短任务放到独立队列及消费资源中,防止一个大任务让所有短请求排在后面;监控长度也监控最老消息等待时间。',
+      '扩 worker 前检查数据库、文件 I/O 和连接池预算。下游已经饱和时,应限并发、降级或减少工作量,而不是继续加线程。',
+    ],
+    misconception:
+      '队列越长不代表系统越稳定。无限排队会把快速失败变成超时、内存消耗和过时结果,重试还可能进一步放大负载。',
+  },
+  {
+    id: 'cache-aside',
+    module: 'redis',
+    lessons: ['01', '02', '04', '07', '09'],
+    overview: true,
+    title: 'Cache Aside:Redis 保存副本,MySQL 保存真值',
+    summary:
+      '读缓存是为了少查数据库;缓存中没有数据是正常路径。业务必须能够从数据库重建缓存。',
+    rows: [
+      '先尝试快速路径:命中缓存即可返回',
+      '未命中时:查真源,再带 TTL 写入可重建副本',
+    ],
+    nodes: [
+      n('request', '查询 API', '读取商品 42', 0, 0),
+      n('cache', 'Redis 缓存', '可过期、可淘汰的副本', 1, 0, 'amber'),
+      n('hit', 'API 得到命中值', '反序列化并返回数据', 2, 0, 'green'),
+      n('miss', 'API 发起回源', 'Redis 不会主动查询数据库', 0, 1),
+      n('db', 'MySQL 真源', '处理 API 发来的查询', 1, 1, 'green'),
+      n(
+        'fill',
+        'API 回填并返回',
+        '写 Redis + TTL,再响应调用方',
+        2,
+        1,
+        'amber',
+      ),
+    ],
+    edges: [
+      e('request', 'cache', '① GET'),
+      e('cache', 'hit', '② 命中'),
+      e('cache', 'miss', '② 未命中'),
+      e('miss', 'db', '③ API 查库'),
+      e('db', 'fill', '④ 返回真值'),
+    ],
+    steps: [
+      'API 先读 Redis。命中则返回;键不存在、TTL 到期或被淘汰时,由 API 决定转去查数据库,Redis 自己不会主动回源。',
+      'API 查到数据后写回 Redis 并设置 TTL,再返回业务结果。不存在的数据可用短 TTL 的负缓存保护,但要考虑创建数据后的失效。',
+      'Redis 故障时,可以在明确容量限制内回退数据库。数据库没有足够余量时,需要限流或降级,不能让所有请求直接涌向数据库。',
+    ],
+    misconception:
+      'Redis 快不等于数据更新。缓存中的副本可能短暂过时;需要强一致的余额、权限等判断,应选择满足要求的真源读取策略。',
+  },
+  {
+    id: 'cache-refill-race',
+    module: 'redis',
+    lessons: ['03', '05'],
+    title: '先提交再删缓存,仍可能发生旧值回填',
+    summary:
+      '按时间从左到右读两条请求:读请求提前拿到旧值,写请求删完缓存后,旧读才把旧值写回去。',
+    rows: [
+      '读请求 R:先读到旧值 v1,暂停后再回填',
+      '写请求 W:在 R 暂停期间,更新真源并删除缓存',
+    ],
+    nodes: [
+      n('read', 'R 缓存未命中', '从数据库读到 v1', 0, 0),
+      n('pause', 'R 暂停 / 变慢', '仍持有之前读出的 v1', 1, 0, 'amber'),
+      n('stale', 'R 晚到的回填', '把 v1 又写入 Redis', 2, 0, 'red'),
+      n('update', 'W 修改数据', '数据库更新为 v2', 0, 1),
+      n('commit', 'W 提交成功', '数据库真值已是 v2', 1, 1, 'green'),
+      n('delete', 'W 删除缓存', '清掉当时已有的副本', 2, 1),
+    ],
+    edges: [
+      e('read', 'pause', '① 读到旧值'),
+      e('pause', 'stale', '④ 稍后回填', 'risk'),
+      e('update', 'commit', '② 更新提交'),
+      e('commit', 'delete', '③ 失效'),
+      e('delete', 'stale', '先删除、后回填', 'risk'),
+    ],
+    steps: [
+      'R 读缓存未命中,从 MySQL 取到了 v1,但因为调度或网络变慢,还没有写入 Redis。',
+      'W 把数据库改成 v2,提交成功后删除缓存。随后 R 恢复,把手里的 v1 回填,产生“数据库 v2、缓存 v1”的短暂不一致。',
+      'TTL 可限制旧副本的存活窗口。更严格的要求可结合版本校验、协调回填与失效、可靠失效事件或直接读取真源;每种方案都要说明故障边界。',
+    ],
+    misconception:
+      '“提交后删除”是常用基线,不是强一致证明。单纯重复删除或延迟双删,若没有时延与失败假设,也不能彻底消除竞争。',
+  },
+  {
+    id: 'cache-storms',
+    module: 'redis',
+    lessons: ['04', '05', '06', '09', '10'],
+    title: '穿透、击穿、雪崩:三种压力来源不一样',
+    summary:
+      '先判断是“本来就不存在”“一个热点失效”还是“大量缓存同时不可用”,才能把保护放在正确的位置。',
+    rows: [
+      '症状:大量请求为什么越过缓存',
+      '对应保护:先挡住放大的请求,再受控访问真源',
+    ],
+    nodes: [
+      n('absent', '穿透', '反复查询根本不存在的键', 0, 0, 'red'),
+      n('hot', '击穿', '同一个热门键突然过期', 1, 0, 'red'),
+      n('many', '雪崩', '大量键失效 / Redis 故障', 2, 0, 'red'),
+      n(
+        'validate',
+        '校验 + 短期负缓存',
+        '先拒绝无效输入,缓存不存在',
+        0,
+        1,
+        'green',
+      ),
+      n('single', '合并同键回填', '单飞、有界等待、必要时旧值', 1, 1, 'green'),
+      n('spread', '打散 + 限流降级', 'TTL 抖动;回源总量有上限', 2, 1, 'green'),
+    ],
+    edges: [
+      e('absent', 'validate', '减少无效回源'),
+      e('hot', 'single', '避免重复重建'),
+      e('many', 'spread', '保护真源容量'),
+    ],
+    steps: [
+      '穿透的键在数据库也不存在。校验参数、短期缓存“查无此项”,可以减少重复无效查询;负缓存要有容量与过期策略。',
+      '击穿集中在单个热点键。让少量请求负责重建,其余请求有界等待或读取业务允许的旧值,避免同一键触发数千次数据库查询。',
+      '雪崩涉及很多键或整个 Redis。TTL 抖动降低同刻过期概率,故障时仍要用回源并发上限、熔断和降级保护数据库。',
+    ],
+    misconception:
+      '给所有缓存都加一个锁,不能解决所有缓存问题。锁服务本身也有容量、过期和失败边界,持锁者退出后必须能够恢复。',
+  },
+  {
+    id: 'redis-roles',
+    module: 'redis',
+    lessons: ['07', '08', '10'],
+    title: '缓存和任务 Broker 使用 Redis,却有不同的数据承诺',
+    summary:
+      '缓存允许按策略淘汰可重建副本;待消费任务被淘汰可能变成业务丢失。因此两类负载需要独立规划。',
+    rows: [
+      '缓存路径:容量不足时允许丢弃可重建副本',
+      '任务路径:消息交接必须有自己的耐久与恢复策略',
+    ],
+    nodes: [
+      n('read', 'API 查询', '优先读取查询副本', 0, 0),
+      n('cache', '缓存 Redis 实例', 'TTL + 内存上限 + 淘汰策略', 1, 0, 'amber'),
+      n('mysql', 'MySQL 真源', '缓存丢了可以重新生成', 2, 0, 'green'),
+      n('publish', 'Outbox 发布进程', '准备交接待执行任务', 0, 1),
+      n('broker', '独立 Broker 实例', '独立资源、持久化与恢复', 1, 1, 'amber'),
+      n('worker', 'Worker', '幂等执行并保存任务状态', 2, 1),
+    ],
+    edges: [
+      e('read', 'cache', '① 查询'),
+      e('read', 'mysql', 'API 未命中后查库', 'sync', {
+        fromPort: 'top',
+        toPort: 'top',
+        via: [
+          [135, 50],
+          [765, 50],
+        ],
+        labelAt: [570, 65],
+      }),
+      e('publish', 'broker', '② 发布', 'async'),
+      e('broker', 'worker', '③ 消费', 'async'),
+    ],
+    steps: [
+      '缓存 Redis 保存可重建数据,可以在达到内存预算时按淘汰策略丢弃副本,API 回真源重建。',
+      'Broker 保存待执行任务,不能把“缓存可丢”直接套用到队列。为 Broker 单独规划内存、持久化、复制、可见性超时和恢复扫描。',
+      '不同键前缀或逻辑数据库编号仍共享同一 Redis 进程和资源;需要故障与容量隔离时,应考虑独立实例及独立预算。',
+    ],
+    misconception:
+      'Redis 开启持久化并不代表零数据丢失;恢复点取决于持久化方式、复制进度与故障类型,必须通过演练验证。',
+  },
+  {
+    id: 'compose-network',
+    module: 'docker',
+    lessons: ['04', '06', '08', '09'],
+    overview: true,
+    title: 'Compose 内部用服务名,主机端口用于外部进入',
+    summary:
+      '每个容器有自己的网络视角。API 容器访问 MySQL 时,目标是 mysql:3306,而不是自己的 localhost。',
+    rows: [
+      '主机外 / 主机入口:示例映射 8080 → 容器内 8000',
+      'Compose 网络内部:用服务 DNS 名称和容器端口通信',
+    ],
+    nodes: [
+      n('browser', '浏览器', '请求主机的 :8080', 0, 0),
+      n('api', 'API 容器', '监听 0.0.0.0:8000', 1, 0),
+      n('worker', 'Worker 容器', '独立任务执行进程', 2, 0),
+      n('mysql', 'mysql:3306', 'MySQL 容器与数据卷', 0, 1, 'green'),
+      n('redis', 'redis:6379', '缓存 Redis 容器', 1, 1, 'amber'),
+      n('broker', 'broker:6379', '独立任务 Broker 容器', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('browser', 'api', '① 8080:8000'),
+      e('api', 'mysql', '② 内部访问'),
+      e('api', 'redis', '③ 查询缓存'),
+      e('worker', 'broker', '④ 拉取任务', 'async'),
+    ],
+    steps: [
+      '主机上的 8080:8000 端口映射,把发向主机 8080 的流量转给 API 容器内的 8000。API 要监听允许容器网络进入的地址。',
+      '同一 Compose 网络内通过服务名解析地址,连接 mysql:3306、redis:6379。内部访问不需要绕到主机映射端口。',
+      'worker 也是独立容器,使用服务名访问 Broker 和数据库。depends_on 或健康检查可以协调启动,但运行中依赖故障仍需要超时与重试。',
+    ],
+    misconception:
+      '容器里的 localhost 指当前容器自身。把数据库端口发布到主机并不是容器之间通信的必要条件,也不应无意暴露外部入口。',
+  },
+  {
+    id: 'image-container-volume',
+    module: 'docker',
+    lessons: ['01', '02', '03', '05', '07', '10'],
+    title: '镜像可以重建,容器可以替换,业务数据要单独保管',
+    summary:
+      '镜像是可重复部署的材料,容器是运行中的进程环境,持久卷承载需要跨容器生命周期保留的数据。',
+    rows: [
+      '应用发布:固定版本镜像创建可替换的容器',
+      '数据生命周期:容器重建与数据备份是不同操作',
+    ],
+    nodes: [
+      n('image', '版本化应用镜像', '代码 + 运行环境 + 固定依赖', 0, 0),
+      n('old', '旧 API 容器', '先停止接收新请求', 1, 0, 'amber'),
+      n('new', '新 API 容器', '健康验证后接收流量', 2, 0, 'green'),
+      n('mysql', 'MySQL 容器', '进程可以重建', 0, 1),
+      n('volume', '独立持久卷', '数据库文件持续存在', 1, 1, 'green'),
+      n('backup', '异地备份', '隔离恢复验证可用性', 2, 1, 'green'),
+    ],
+    edges: [
+      e('image', 'old', '运行旧版本'),
+      e('old', 'new', '有序替换'),
+      e('mysql', 'volume', '持久数据'),
+      e('volume', 'backup', '备份与恢复验证', 'async'),
+    ],
+    steps: [
+      '使用明确镜像版本发布,先验证新容器健康,再排空旧容器请求并优雅退出。worker 停止时还要处理未完成任务的租约与确认。',
+      '数据库文件放在独立持久卷,替换 MySQL 容器时复用卷;应用容器可写层中的临时文件则可能随重建丢失。',
+      '持久卷只能跨容器保留数据,不能抵御误删、主机故障或错误写入。独立备份、恢复演练和兼容数据库迁移共同决定可回退能力。',
+    ],
+    misconception:
+      '卷不是备份,镜像回滚也不等于数据结构自动回滚。删除卷会影响持久数据,发布流程不应把清理容器与清理数据混为一谈。',
+  },
+  {
+    id: 'linux-proxy',
+    module: 'linux',
+    lessons: ['01', '02', '03', '05', '06'],
+    overview: true,
+    title: '公网入口、反向代理、应用进程各守一层边界',
+    summary:
+      '请求先到服务器入口,再经 Nginx 转交 API。TLS、流式缓冲和连接超时可能在不同层影响同一次访问。',
+    rows: [
+      '入口路径:名字找到地址,端口规则决定能否连接',
+      '服务内部:代理转发到应用,应用再访问私有依赖',
+    ],
+    nodes: [
+      n('client', '用户浏览器', '访问 https://课程服务', 0, 0),
+      n('network', 'DNS + 网络入口', '域名解析;防火墙规则', 1, 0),
+      n('nginx', 'Nginx :443', 'TLS 终止、代理、访问日志', 2, 0),
+      n('data', 'MySQL / Redis', '限制来源的内部依赖', 0, 1, 'green'),
+      n('api', 'API 进程', '例如监听 127.0.0.1:8000', 1, 1),
+      n('forward', '代理转发规则', '路径、连接头、超时与缓冲', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('client', 'network', '① 查址并连接'),
+      e('network', 'nginx', '② TLS / HTTP'),
+      e('nginx', 'forward', '③ 选择上游'),
+      e('forward', 'api', '④ 转发'),
+      e('api', 'data', '⑤ 内部调用'),
+    ],
+    steps: [
+      '域名解析回答“去哪个地址”,防火墙和监听端口决定“能不能连上”。TLS 握手发生在 HTTP 请求处理之前。',
+      'Nginx 接收公网 HTTPS,再将请求转发到本机或内部网络中的 API 进程。不要默认把数据库和应用管理端口暴露给公网。',
+      'SSE 需要检查代理缓冲、空闲超时与心跳;WebSocket 还需要正确转发升级连接。应用自己工作正常,代理层也可能让体验异常。',
+    ],
+    misconception:
+      '看到 502 并不能直接认定业务代码有错;可能是上游进程未启动、地址写错、连接被拒绝或代理与上游协议不匹配。',
+  },
+  {
+    id: 'process-supervision',
+    module: 'linux',
+    lessons: ['04', '07', '08', '09', '10'],
+    title: 'systemd 管进程,发布流程决定何时把流量交给它',
+    summary:
+      '进程存在只是起点。可靠发布还要验证依赖可用、接口正常、旧请求排空,以及失败时的恢复路径。',
+    rows: [
+      '正常发布:准备版本 → 管理进程 → 验证健康',
+      '退出与故障:先排空,后停止;恢复必须验证业务结果',
+    ],
+    nodes: [
+      n('release', '准备新版本', '依赖、配置与兼容迁移', 0, 0),
+      n('systemd', 'systemd 服务单元', '运行账户、目录、重启策略', 1, 0),
+      n('health', '就绪与业务检查', '接口成功且依赖可访问', 2, 0, 'green'),
+      n('verify', '验证恢复', '检查错误率、任务与数据', 0, 1, 'green'),
+      n('restore', '重启 / 兼容回退', '按 runbook 选择恢复动作', 1, 1),
+      n('drain', '排空旧进程', '拒绝新工作;完成或交还旧工作', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('release', 'systemd', '① 启动'),
+      e('systemd', 'health', '② 验证'),
+      e('health', 'drain', '③ 交接流量'),
+      e('drain', 'restore', '异常恢复', 'risk'),
+      e('restore', 'verify', '④ 业务验收'),
+    ],
+    steps: [
+      'systemd 按固定账户、工作目录和配置启动 API、worker 等独立进程,记录日志并执行约定的重启策略。',
+      '发布流程先做就绪检查和关键业务验收,再接入流量。停止旧进程时留出排空时间;超时强停后的工作应有幂等与租约恢复路径。',
+      '重启、回退代码或恢复备份是不同修复动作。根据证据选择后,继续验证请求成功率、任务终态和数据完整性,确认恢复目标达到。',
+    ],
+    misconception:
+      '自动重启不会修复错误配置、数据库损坏或持续过载;无限重启还可能制造更多连接和告警,需要明确退出条件和人工可执行手册。',
+  },
+  {
+    id: 'trace-evidence',
+    module: 'troubleshooting',
+    lessons: ['01', '02', '03', '04', '07', '08', '11', '12'],
+    overview: true,
+    title: '同一条证据链跨过 HTTP、数据库与任务消息',
+    summary:
+      '指标先告诉你“哪里异常”,追踪帮助缩小“慢在哪一段”,结构化日志解释“当时发生了什么”。',
+    rows: [
+      '同步请求:传播 trace 上下文,分别记录阶段耗时',
+      '异步任务:随消息传递关联信息,并记录排队与执行时间',
+    ],
+    nodes: [
+      n('request', '用户请求', 'request_id / trace_id', 0, 0),
+      n('api', 'API 请求跨度', '等待连接、业务处理、外部 I/O', 1, 0),
+      n('db', '数据库调用跨度', '查询耗时与锁等待证据', 2, 0, 'green'),
+      n('timeline', '关联追踪与日志', '按 ID 串联,不记录敏感载荷', 0, 1),
+      n(
+        'queue',
+        '消息携带上下文',
+        'job_id + 发布时刻 + trace 信息',
+        1,
+        1,
+        'amber',
+      ),
+      n('worker', 'Worker 执行跨度', '收到时刻、开始、完成、重试', 2, 1),
+    ],
+    edges: [
+      e('request', 'api', '① 传播上下文'),
+      e('api', 'db', '② 子调用'),
+      e('api', 'queue', '③ 注入消息', 'async'),
+      e('queue', 'worker', '④ 提取上下文', 'async'),
+      e('worker', 'timeline', '⑤ 关联证据', 'async', {
+        fromPort: 'bottom',
+        toPort: 'bottom',
+        via: [
+          [765, 388],
+          [135, 388],
+        ],
+        labelAt: [450, 383],
+      }),
+    ],
+    steps: [
+      '从影响范围、错误率和延迟指标入手,用请求 ID 找到具体请求。指标标签用服务、路由等有限集合,避免把每个 request_id 都当标签。',
+      '传播追踪上下文,让 API、外部请求和数据库调用能够关联。连接池等待应单独测量,否则可能误把应用排队当作慢 SQL。',
+      '把上下文与 job_id 放进任务消息,按追踪模型建立父子关系或 span link。拆开“发布到开始的等待时间”和“实际执行时间”,才能看见队列积压。',
+    ],
+    misconception:
+      '日志里都打印一个 UUID,并不自动形成分布式追踪;还需要上下文传播、跨度关系、耗时记录和可查询的采集系统。',
+    source: {
+      label: 'OpenTelemetry:上下文传播',
+      url: 'https://opentelemetry.io/docs/concepts/context-propagation/',
+    },
+  },
+  {
+    id: 'saturation-diagnosis',
+    module: 'troubleshooting',
+    lessons: ['05', '06', '08', '09', '10', '12'],
+    title: '请求变慢时,先看工作卡在哪一种等候区',
+    summary:
+      'CPU 忙、连接池满、数据库锁等待和队列积压都会表现为“慢”,但加 worker 只能帮助其中一部分情况。',
+    rows: [
+      '观察症状:延迟上升,但要继续定位等待发生的位置',
+      '验证假设:找对应指标,再选择能够消除瓶颈的动作',
+    ],
+    nodes: [
+      n('loop', '事件循环迟迟不调度', '轻量健康检查也变慢', 0, 0, 'red'),
+      n('pool', '借数据库连接很慢', '池等待高,活跃连接满', 1, 0, 'red'),
+      n('queue', '任务越来越晚开始', '最老消息年龄持续上升', 2, 0, 'red'),
+      n('cpu', '查阻塞与 CPU', '同步调用?计算热点?线程池?', 0, 1, 'green'),
+      n('sql', '查事务与锁', '长事务?慢 SQL?连接未归还?', 1, 1, 'green'),
+      n(
+        'capacity',
+        '查消费与到达率',
+        '重试风暴?下游限额?租约?',
+        2,
+        1,
+        'green',
+      ),
+    ],
+    edges: [
+      e('loop', 'cpu', '收集调度证据'),
+      e('pool', 'sql', '拆分等待时间'),
+      e('queue', 'capacity', '区分排队与执行'),
+    ],
+    steps: [
+      '如果事件循环延迟升高,先检查阻塞 I/O 和 CPU 密集工作。CPU 指标、线程栈和请求耗时可以区分“计算忙”和“被阻塞”。',
+      '如果连接池等待升高,再看连接占用时长、SQL 耗时与数据库锁等待。盲目扩大池可能把数据库推向更严重的过载。',
+      '如果任务开始得越来越晚,比较到达率与完成率,核查失败重试、worker 存活、Broker 与租约恢复。先限制压力,再用修复后的业务指标确认恢复。',
+    ],
+    misconception:
+      'CPU 使用率不高不代表系统健康:锁、网络、连接池和外部配额都能让系统在“等待”中失去处理能力。',
+  },
+  {
+    id: 'distributed-evolution',
+    module: 'distributed',
+    lessons: ['01'],
+    overview: true,
+    title: '架构演进:每一次拆分都要对应一个真实压力',
+    summary:
+      '从容易理解的单体开始,先明确边界,再按扩容、故障隔离或团队协作需求拆分。节点增加也意味着网络与一致性成本增加。',
+    rows: [
+      '业务组织方式:边界逐渐明确,但复杂度也逐渐增加',
+      '商品服务:详情查询与导出任务使用不同执行资源',
+    ],
+    nodes: [
+      n('monolith', '单体应用', '一个部署单元,内部直接调用', 0, 0),
+      n('modular', '模块化单体', '按领域分层,仍可一起部署', 1, 0),
+      n('services', '独立服务', '网络调用、独立发布与扩容', 2, 0, 'amber'),
+      n('api', '无状态 API 副本', '受理、鉴权、查询与流式输出', 0, 1),
+      n('queue', '任务交接层', '隔离长任务,设置受理边界', 1, 1, 'amber'),
+      n(
+        'worker',
+        'Worker + 数据真源',
+        '独立执行预算,持久任务状态',
+        2,
+        1,
+        'green',
+      ),
+    ],
+    edges: [
+      e('monolith', 'modular', '明确内部边界'),
+      e('modular', 'services', '按需求拆分'),
+      e('api', 'queue', '异步交接', 'async'),
+      e('queue', 'worker', '有界消费', 'async'),
+    ],
+    steps: [
+      '面试先说业务需求、峰值流量、数据规模和一致性要求,再提出满足当前约束的最小架构。',
+      '模块化单体可以先隔离职责,减少改动互相影响。只有遇到独立扩容、发布节奏或故障隔离需求时,才引入新的服务边界。',
+      '商品详情查询与清单导出可能占用不同资源,可先把导出任务执行拆成 worker。拆分后要补上超时、幂等、状态与观测。',
+    ],
+    misconception:
+      '微服务数量多不代表设计成熟。一次本地函数调用变成网络调用后,就新增了超时、部分失败、版本兼容和数据一致性问题。',
+  },
+  {
+    id: 'distributed-ha',
+    module: 'distributed',
+    lessons: ['02'],
+    title: '负载均衡绕开坏副本,也要避免更深处的单点',
+    summary:
+      'API 有多个副本只是高可用的一层。负载均衡、数据存储和跨可用区部署也需要各自的故障假设。',
+    rows: [
+      '请求入口:均衡器只把新流量发给可服务的副本',
+      '副本故障:摘除、排空和重试需要业务配合',
+    ],
+    nodes: [
+      n('client', '调用方', '请求携带身份与业务上下文', 0, 0),
+      n('lb', '负载均衡层', '选择健康副本;自身需冗余', 1, 0),
+      n('a', 'API 副本 A', '健康:继续接收流量', 2, 0, 'green'),
+      n('data', '共享持久状态', '独立设计复制与恢复', 0, 1, 'green'),
+      n('probe', '健康探测与摘除', '从路由中移除不可用副本', 1, 1),
+      n('b', 'API 副本 B 故障', '已接收请求仍可能结果未知', 2, 1, 'red'),
+    ],
+    edges: [
+      e('client', 'lb', '① 发请求'),
+      e('lb', 'a', '② 健康路由'),
+      e('b', 'probe', '③ 故障证据', 'risk'),
+      e('probe', 'lb', '④ 更新路由'),
+      e('probe', 'data', '核查依赖状态'),
+    ],
+    steps: [
+      'API 副本尽量不在本机内存保存唯一业务状态,这样某个副本退出后,其他副本仍能查询同一任务与用户数据。',
+      '健康探测发现不可服务副本后,均衡器停止发送新流量。已经送出的请求不一定失败,也可能已经提交但响应没送回。',
+      '继续检查均衡入口、数据库和共享存储是否仍是单点;跨机或跨可用区部署要权衡延迟、成本与故障范围。',
+    ],
+    misconception:
+      '负载均衡不会把失败请求自动变成安全请求。写操作重试仍需要幂等,长连接故障通常需要客户端重连及应用层恢复。',
+  },
+  {
+    id: 'replication-sharding',
+    module: 'distributed',
+    lessons: ['03'],
+    title: '复制是多份相同数据,分片是把数据拆成不同部分',
+    summary:
+      '复制常用于容错和读扩展,分片常用于容量和写入扩展。它们可以组合,但解决的问题不同。',
+    rows: [
+      '复制:同一份数据传播到多个节点,会有进度差异',
+      '分片:按路由键决定某条数据归哪个节点管理',
+    ],
+    nodes: [
+      n('primary', '主节点', '接收写入,产生变更记录', 0, 0, 'green'),
+      n('log', '复制链路', '同步 / 异步有不同取舍', 1, 0, 'amber'),
+      n('replica', '副本节点', '保存副本,可能暂时落后', 2, 0),
+      n('router', '分片路由', '例如按 tenant_id 决定归属', 0, 1),
+      n('shard1', '分片 A', '一部分租户的数据', 1, 1, 'green'),
+      n('shard2', '分片 B', '另一部分租户的数据', 2, 1, 'green'),
+    ],
+    edges: [
+      e('primary', 'log', '① 变更传播', 'async'),
+      e('log', 'replica', '② 应用变更', 'async'),
+      e('router', 'shard1', '键属于 A'),
+      e('router', 'shard2', '键属于 B', 'sync', {
+        fromPort: 'bottom',
+        toPort: 'bottom',
+        via: [
+          [135, 388],
+          [765, 388],
+        ],
+        labelAt: [450, 383],
+      }),
+    ],
+    steps: [
+      '复制让多个节点拥有同一份数据。异步复制下,刚写主库就读副本可能看不到更新;故障切换还要考虑未复制写入。',
+      '分片把不同数据分给不同节点。分片键决定负载是否均匀,也影响查询是否必须跨多个节点聚合。',
+      '常见设计是在每个分片内部再做复制。面试要继续解释热点分片、扩容迁移、跨片事务与路由变化,不能止于画出两个数据库。',
+    ],
+    misconception:
+      '增加只读副本通常不能直接分担主节点写入。复制也会把误删传播出去,所以它不能代替独立备份。',
+  },
+  {
+    id: 'cap-partition',
+    module: 'distributed',
+    lessons: ['04'],
+    title: '网络分区时,两边能否同时承诺最新且可写',
+    summary:
+      '分区让节点无法确认彼此的最新状态。CAP 讨论这种条件下,一致性与可用性不能同时完全满足的限制。',
+    rows: [
+      '故障事实:节点仍在运行,但互相收不到消息',
+      '业务选择:拒绝无法保证正确的操作,或接受稍后协调',
+    ],
+    nodes: [
+      n('a', '节点 A', '已收到一份新的写入', 0, 0),
+      n('partition', '网络分区', 'A 与 B 之间无法通信', 1, 0, 'red'),
+      n('b', '节点 B', '无法确认 A 的最新状态', 2, 0),
+      n(
+        'consistent',
+        '优先保证一致语义',
+        '无足够证据的一侧拒绝请求',
+        0,
+        1,
+        'amber',
+      ),
+      n('rule', '按业务操作划界', '扣款与点赞可选不同语义', 1, 1),
+      n('available', '保持本地可用', '允许暂时分歧,之后协调', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('a', 'partition', '通信中断', 'risk'),
+      e('b', 'partition', '通信中断', 'risk'),
+      e('a', 'consistent', '选择一'),
+      e('b', 'available', '选择二'),
+    ],
+    steps: [
+      '这里的分区指网络通信被切断,不是数据库分片。A 和 B 进程都可能健康,但不能依赖彼此立即返回信息。',
+      '若要求线性一致性,就不能让无法形成必要共识的一侧随意接受成功写入;若保持双方可用,就要接受并发状态和后续冲突处理。',
+      'CAP 的可用性与运维中的月度 SLA 不是同一概念。没有分区时,仍存在复制等待、延迟和一致性之间的工程取舍。',
+    ],
+    misconception:
+      'CAP 不是把数据库永久贴成“任意三选二”。要明确发生了什么故障、哪一类操作、采用什么一致性定义,以及拒绝请求的范围。',
+  },
+  {
+    id: 'cache-consistency-design',
+    module: 'distributed',
+    lessons: ['05'],
+    title: '设计缓存前,先给“可以旧多久”一个答案',
+    summary:
+      '缓存一致性是业务选择:有些页面接受短暂旧值,有些授权或余额判断必须读满足一致性要求的数据源。',
+    rows: [
+      '读路径按要求分流:允许旧值与要求新值使用不同策略',
+      '更新路径:真源提交以后,再传播可恢复的缓存失效',
+    ],
+    nodes: [
+      n('read', '读请求', '声明新鲜度与权限要求', 0, 0),
+      n('cache', '允许短暂旧值', '版本化缓存 + TTL + 降级', 1, 0, 'amber'),
+      n('source', '需要严格判断', '读取满足一致性要求的真源', 2, 0, 'green'),
+      n('write', '业务更新', '写真源并提交事务', 0, 1),
+      n('event', '可靠失效意图', '重试、顺序 / 版本与告警', 1, 1),
+      n(
+        'invalidate',
+        '删除 / 更新缓存',
+        '处理回填竞态与传播延迟',
+        2,
+        1,
+        'amber',
+      ),
+    ],
+    edges: [
+      e('read', 'cache', '选择快速路径'),
+      e('read', 'source', '选择严格路径', 'sync', {
+        fromPort: 'top',
+        toPort: 'top',
+        via: [
+          [135, 50],
+          [765, 50],
+        ],
+        labelAt: [450, 65],
+      }),
+      e('write', 'event', '① 持久化意图'),
+      e('event', 'invalidate', '② 异步传播', 'async'),
+    ],
+    steps: [
+      '先区分展示内容和必须正确的业务判断,约定最大可接受陈旧窗口、缓存不可用时的行为与数据库容量余量。',
+      '普通内容采用 Cache Aside、TTL 和提交后失效时,要承认失效传播与旧值回填窗口;更严格的要求需要额外的版本或协调机制。',
+      '缓存故障时回源并发也必须受控。可选限流、只读降级或使用允许的旧值,并记录命中率、回源量和数据新鲜度。',
+    ],
+    misconception:
+      '给缓存设置 60 秒 TTL,不等于所有读者总能在数据库更新后 60 秒内看到新值;回填竞态、时钟和多层缓存都会影响承诺。',
+  },
+  {
+    id: 'message-failure-path',
+    module: 'distributed',
+    lessons: ['06'],
+    title: '消息系统要画出失败分支,不能只画生产和消费',
+    summary:
+      '解耦、削峰和异步交接是收益;消息是否丢失、是否重复、积压多久以及毒消息如何隔离,需要逐项说明。',
+    rows: [
+      '正常路径:可靠记录意图、投递、持久化结果与确认',
+      '失败路径:预算内重试,超限隔离,修复后可控重放',
+    ],
+    nodes: [
+      n('producer', '生产方 + 发送意图', '业务提交与交接记录关联', 0, 0),
+      n('broker', 'Broker', '耐久策略、确认与队列容量', 1, 0, 'amber'),
+      n('consumer', '幂等消费者', '提交副作用后按策略确认', 2, 0),
+      n('replay', '修复后重放', '保留事件 ID,限制重放速率', 0, 1, 'green'),
+      n('dead', '失败隔离区', '记录原因、次数与告警', 1, 1, 'red'),
+      n('retry', '有限重试', '退避 + 抖动 + 总截止时间', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('producer', 'broker', '① 发布', 'async'),
+      e('broker', 'consumer', '② 投递', 'async'),
+      e('consumer', 'retry', '③ 可重试失败', 'risk'),
+      e('retry', 'dead', '④ 预算耗尽', 'risk'),
+      e('dead', 'replay', '⑤ 修复与审核'),
+    ],
+    steps: [
+      '从发布方到 Broker 的接收确认,与消费者到 Broker 的消费确认,是两个阶段;业务结果提交还属于另一个系统。',
+      '按至少一次交付设计幂等消费者,明确同一业务键的顺序要求。可重试错误用退避和抖动,永久错误尽早进入失败隔离。',
+      '死信或失败表不是垃圾桶:要有告警、负责人、检查原因和重放流程。重放保留幂等键,避免修复时再次重复副作用。',
+    ],
+    misconception:
+      '“用了消息队列就不会丢”和“开启重试就一定成功”都缺少故障假设。永远失败的消息无限重试会拖累正常业务。',
+  },
+  {
+    id: 'saga-compensation',
+    module: 'distributed',
+    lessons: ['07'],
+    title: 'Saga 用业务补偿处理已跨出本地事务的变化',
+    summary:
+      '用商品清单发布作理论推演:记录导出任务、发布下载入口各自提交,后续失败时撤销入口并标记失败。补偿本身也可能失败。',
+    rows: [
+      '正向步骤:每一步都有自己的本地提交与交接记录',
+      '后续失败:按业务规则补偿,补偿动作也要幂等和可恢复',
+    ],
+    nodes: [
+      n('reserve', '登记导出任务', '任务服务本地提交', 0, 0, 'green'),
+      n('charge', '发布下载入口', '目录服务本地提交', 1, 0, 'green'),
+      n('fulfill', '文件发布失败', '文件服务未完成发布', 2, 0, 'red'),
+      n('release', '标记任务失败', '可重试、记录补偿状态', 0, 1),
+      n('refund', '撤销下载入口', '是新业务动作,可能失败', 1, 1, 'amber'),
+      n('coordinate', '流程协调 / 事件', '决定补偿次序并追踪进度', 2, 1),
+    ],
+    edges: [
+      e('reserve', 'charge', '① 正向推进', 'async'),
+      e('charge', 'fulfill', '② 正向推进', 'async'),
+      e('fulfill', 'coordinate', '③ 记录失败', 'risk'),
+      e('coordinate', 'refund', '④ 撤销入口', 'async'),
+      e('refund', 'release', '⑤ 标记失败', 'async'),
+    ],
+    steps: [
+      '跨服务流程无法直接依赖一个数据库本地事务。每个参与者提交自己的数据,并通过可靠事件或协调器推进下一步。',
+      '文件发布失败时,撤销已发布的下载入口并把任务标为失败。已提交的记录通过新操作修正;Outbox 可以让本地状态和发送意图一起提交。',
+      '补偿是新的业务动作,不是把时间倒回。它需要幂等、重试、审计,长期失败时还需要人工处理和对账。',
+    ],
+    misconception:
+      '此图故意把下载入口先于文件发布,以便观察补偿;实际应优先让文件就绪后再开放下载。简单商品服务不需要为了学习 Saga 拆成多个服务;两者都不是全局 ACID 保证。',
+  },
+  {
+    id: 'idempotent-retry',
+    module: 'distributed',
+    lessons: ['08'],
+    title: '响应丢了,重试同一意图必须复用同一把钥匙',
+    summary:
+      '客户端看见超时,只能说明没有收到确定结果。服务端可能已经提交,所以再次提交要携带稳定的幂等键。',
+    rows: [
+      '第一次请求:服务端已提交,但响应没能到达调用方',
+      '重复请求:数据库原子识别同一意图,返回原结果',
+    ],
+    nodes: [
+      n('first', '首次提交', 'key=K;请求内容摘要=H', 0, 0),
+      n('commit', '原子登记与业务写入', '唯一约束保护同一 key', 1, 0, 'green'),
+      n('lost', '响应丢失 / 超时', '客户端不知道是否已成功', 2, 0, 'red'),
+      n('repeat', '重试同一请求', '继续使用 K 与相同内容', 0, 1),
+      n('lookup', '发现已处理的 K', '核对身份、范围与请求摘要', 1, 1),
+      n('reuse', '返回已保存结果', '正在处理时返回明确状态', 2, 1, 'green'),
+    ],
+    edges: [
+      e('first', 'commit', '① 提交意图'),
+      e('commit', 'lost', '② 返回失败', 'risk'),
+      e('first', 'repeat', '③ 结果未知后'),
+      e('repeat', 'lookup', '④ 原子判重'),
+      e('lookup', 'reuse', '⑤ 复用结果'),
+    ],
+    steps: [
+      '客户端为一次业务意图生成稳定幂等键,重试时保留同一键;服务端按用户、业务类型等范围设置唯一约束。',
+      '幂等登记应与本地业务写入原子协调,保存处理中或已完成状态及结果。相同键但不同请求内容应明确拒绝。',
+      '对外部副作用,把同一幂等意图继续传播给支持幂等的下游;不支持时需要状态查询、对账或人工补偿。还要定义幂等记录保留期。',
+    ],
+    misconception:
+      '每次重试生成新 UUID,只会创建新的业务意图。先查询“没有这个键”再无约束写入,也无法阻止并发重复。',
+  },
+  {
+    id: 'fencing-token',
+    module: 'distributed',
+    lessons: ['09'],
+    title: '租约过期后,旧持有者还可能继续写',
+    summary:
+      '锁的有效期只是一份执行权声明,不是远程进程停止证明。受保护资源要识别并拒绝过时的执行令牌。',
+    rows: [
+      '旧执行者 A:取得令牌 41 后长时间暂停,后来恢复',
+      '新执行者 B:取得更大的令牌 42,资源拒绝更旧的写入',
+    ],
+    nodes: [
+      n('a', '执行者 A', '持有租约 / 令牌 41', 0, 0),
+      n('pause', '长暂停超过租约', '网络、调度或进程暂停', 1, 0, 'amber'),
+      n('reject', '旧写入被拒绝', '41 < 资源已见到的 42', 2, 0, 'red'),
+      n('b', '执行者 B', '新租约 / 单调令牌 42', 0, 1),
+      n('resource', '受保护资源', '原子校验并记录最大令牌', 1, 1, 'green'),
+      n('accept', '接收 B 的写入', '当前执行权属于新持有者', 2, 1, 'green'),
+    ],
+    edges: [
+      e('a', 'pause', '① 旧持有者'),
+      e('pause', 'reject', '④ 恢复后旧写', 'risk'),
+      e('b', 'resource', '② 携带 42'),
+      e('resource', 'accept', '③ 校验通过'),
+      e('accept', 'reject', '资源已见 42'),
+    ],
+    steps: [
+      'A 取得租约后暂停,租约过期并不会杀死 A。B 取得新租约后,A 仍可能恢复并试图写结果。',
+      'fencing token 是能够比较新旧顺序的令牌。B 携带 42 写资源后,资源原子拒绝随后到来的较小令牌 41。',
+      '需要令牌签发机制提供足够的单调保证,也需要最终写入目标真正校验令牌。只在应用里打印或检查令牌,不能保护不参与校验的外部系统。',
+    ],
+    misconception:
+      'Redis 锁中的随机值可用于避免误删别人的锁,但它本身不是单调 fencing token。锁、租约、幂等和数据库约束分别承担不同责任。',
+  },
+  {
+    id: 'timeout-circuit',
+    module: 'distributed',
+    lessons: ['10'],
+    title: '把一次调用的时间和尝试次数当作有限预算',
+    summary:
+      '超时限制一次等待,重试处理可能恢复的故障,熔断减少持续失败时的调用。三者需要放在同一总预算内。',
+    rows: [
+      '调用开始:先检查总截止时间和依赖健康状态',
+      '失败之后:只对允许重试的意图做有限次数尝试',
+    ],
+    nodes: [
+      n('caller', '上游请求', '例如总预算 3 秒', 0, 0),
+      n('breaker', '熔断与并发门槛', '持续失败时快速拒绝', 1, 0, 'amber'),
+      n('attempt', '一次下游调用', '连接 + 读取 + 执行等待', 2, 0),
+      n('finish', '结束并明确结果', '成功 / 失败 / 结果未知', 0, 1),
+      n('backoff', '退避与抖动', '剩余预算不足就停止', 1, 1, 'amber'),
+      n('classify', '分类失败原因', '幂等?暂态错误?可查询?', 2, 1),
+    ],
+    edges: [
+      e('caller', 'breaker', '① 检查预算'),
+      e('breaker', 'attempt', '② 允许调用'),
+      e('attempt', 'classify', '③ 返回 / 超时'),
+      e('classify', 'backoff', '④ 可重试'),
+      e('backoff', 'finish', '⑤ 预算耗尽'),
+      e('backoff', 'breaker', '预算内再试', 'async'),
+    ],
+    steps: [
+      '从用户请求的总截止时间向下分配预算,包含连接等待、排队、一次调用和重试退避;不能让每一层都独占完整超时。',
+      '只重试明确可重试且副作用可控的操作。退避拉开尝试,随机抖动避免所有客户端同一时刻重试。',
+      '熔断器在失败达到门槛时暂时拒绝依赖调用,并用受控探测判断是否恢复。熔断不是自动修复,应配合告警和降级。',
+    ],
+    misconception:
+      '本地等待超时不代表远端已停止。三层服务各重试 3 次,最底层尝试可能相乘,造成重试风暴。',
+  },
+  {
+    id: 'capacity-design',
+    module: 'distributed',
+    lessons: ['11'],
+    title: '容量预算要贯穿入口、队列、执行器和依赖',
+    summary:
+      '限流控制到达速度,并发限制控制在途工作,背压把容量不足传回上游。队列只能临时吸收差额。',
+    rows: [
+      '三个控制点:不要让有限的下游承受无限的输入',
+      '衡量容量:同时关注吞吐、等待时间与在途数量',
+    ],
+    nodes: [
+      n('rate', '入口限流', '按租户与全局限制受理率', 0, 0),
+      n('queue', '有界队列', '长度上限 + 最长可接受等待', 1, 0, 'amber'),
+      n('concurrency', '执行并发上限', '按数据库与文件 I/O 约束', 2, 0),
+      n('arrival', '到达率 λ', '稳定期间每秒进入多少工作', 0, 1),
+      n('time', '平均耗时 W', '含排队与实际处理时间', 1, 1),
+      n('inflight', '平均在途量 L', '稳定系统中 L = λ × W', 2, 1, 'green'),
+    ],
+    edges: [
+      e('rate', 'queue', '① 有容量才受理', 'async'),
+      e('queue', 'concurrency', '② 有空位才执行', 'async'),
+      e('arrival', 'time', '容量估算输入'),
+      e('time', 'inflight', '关联在途预算'),
+    ],
+    steps: [
+      '入口限流保护公平与总受理量;执行器并发上限限制同时占用的资源;队列满或等待过久时向调用方明确拒绝或降级。',
+      '在稳定系统的长期平均意义下,Little 定律关联在途量、到达率和停留时间。例如每秒完成 20 件、平均停留 5 秒,对应平均约 100 件在途。',
+      '商品查询和文件写入变慢会增加资源占用时间,即使请求数不变也可能过载。扩容时同时计算 API、worker、数据库连接和第三方配额。',
+    ],
+    misconception:
+      '用平均值估算不等于覆盖峰值和长尾;持续超载的队列没有稳定平均状态,不能只套公式后宣称容量足够。',
+  },
+  {
+    id: 'design-recovery',
+    module: 'distributed',
+    lessons: ['12'],
+    title: '系统设计的最后一段:如何证明它能恢复',
+    summary:
+      '把可观测性和容灾放回业务目标:知道什么时候算坏了、能丢多少数据、多久必须恢复,并用演练给出证据。',
+    rows: [
+      '先定义目标与故障假设,再设计可观测证据',
+      '发生故障时:按手册恢复,并核验业务与数据目标',
+    ],
+    nodes: [
+      n('goal', '业务目标', 'SLI / SLO;关键用户路径', 0, 0),
+      n('budget', '恢复目标', 'RPO 可丢数据;RTO 恢复时间', 1, 0),
+      n(
+        'evidence',
+        '指标 / 日志 / 追踪',
+        '能定位故障范围与阶段',
+        2,
+        0,
+        'green',
+      ),
+      n('review', '验收与改进', '验证目标;补上薄弱环节', 0, 1, 'green'),
+      n('restore', '恢复与对账', '切换、回退、恢复备份、重放', 1, 1),
+      n('drill', '故障演练', '节点退出、分区、队列与数据故障', 2, 1, 'amber'),
+    ],
+    edges: [
+      e('goal', 'budget', '① 明确承诺'),
+      e('budget', 'evidence', '② 布置证据'),
+      e('evidence', 'drill', '③ 验证告警'),
+      e('drill', 'restore', '④ 执行手册'),
+      e('restore', 'review', '⑤ 核验结果'),
+    ],
+    steps: [
+      '先定义关键路径的成功率、延迟与可接受故障窗口,再说明 RPO 和 RTO。它们需要落到备份频率、复制策略与恢复流程。',
+      '用指标发现问题,用追踪和日志定位;为任务记录受理到完成的端到端时延,避免只看 API 返回 202 的成功率。',
+      '在隔离环境演练恢复备份、切换副本、重放消息和对账,测量真实恢复时间与数据差异,再更新设计和 runbook。',
+    ],
+    misconception:
+      '“有备份”“有副本”“能自动重启”都不能单独证明可恢复。未验证的备份可能无法还原,副本也可能复制了错误数据。',
+  },
+];
+
+export function getModuleDiagrams(module: string) {
+  return teachingDiagrams.filter(
+    (diagram) => diagram.module === module && diagram.overview,
+  );
+}
+
+export function getLessonDiagrams(module: string, lesson: string) {
+  return teachingDiagrams.filter(
+    (diagram) => diagram.module === module && diagram.lessons.includes(lesson),
+  );
+}
+
+export function hasLessonDiagrams(module: string, lesson: string) {
+  return teachingDiagrams.some(
+    (diagram) => diagram.module === module && diagram.lessons.includes(lesson),
+  );
+}

+ 126 - 0
app/globals.css

@@ -238,6 +238,9 @@ svg {
   min-width: 0;
   flex: 1;
 }
+.interview-followups {
+  margin-top: 22px;
+}
 .topbar {
   height: 69px;
   border-bottom: 1px solid var(--border);
@@ -285,6 +288,32 @@ svg {
 .document-grid > main {
   min-width: 0;
 }
+.document-grid.document-grid-home {
+  grid-template-columns: minmax(0, 1fr);
+  max-width: 1360px;
+  gap: 26px;
+}
+.document-grid-home > .toc {
+  display: flex;
+  grid-row: 1;
+  flex-wrap: wrap;
+  align-items: center;
+  position: static;
+  gap: 6px 24px;
+  padding-bottom: 18px;
+  border-bottom: 1px solid var(--border);
+}
+.document-grid-home > .toc > p {
+  margin: 0;
+}
+.document-grid-home > .toc > a {
+  padding: 4px 0;
+  margin: 0;
+  border: 0;
+}
+.document-grid-home > .toc .toc-note {
+  display: none;
+}
 .eyebrow {
   display: flex;
   align-items: center;
@@ -945,6 +974,103 @@ h1:focus {
 .practice-step {
   margin: 26px 0 34px;
 }
+.lesson-start {
+  margin: 30px 0;
+}
+.lesson-start h2 {
+  margin-bottom: 16px;
+}
+.lesson-start h3 {
+  margin: 22px 0 10px;
+  font-size: 15px;
+}
+.lesson-scenario,
+.lesson-start li,
+.lesson-outcome p,
+.step-observation p,
+.step-explanation p {
+  font-size: 15px;
+  line-height: 1.95;
+  color: #45536a;
+  white-space: pre-line;
+  overflow-wrap: anywhere;
+}
+.lesson-start ul {
+  padding-left: 21px;
+}
+.lesson-start li + li {
+  margin-top: 8px;
+}
+.lesson-start-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 8px;
+  font-size: 13px;
+  line-height: 1.8;
+  color: #2559df;
+}
+.lesson-start-link svg {
+  flex-shrink: 0;
+}
+.lesson-start-link:hover {
+  text-decoration: underline;
+}
+.lesson-outcome {
+  border-left: 3px solid #2f6bff;
+  padding: 2px 0 2px 16px;
+  margin: 20px 0;
+}
+.lesson-outcome > span {
+  font-size: 12px;
+  font-weight: 600;
+  color: #2559df;
+}
+.lesson-outcome p {
+  margin: 5px 0 0;
+}
+.lesson-reading-guide,
+.learning-section-note {
+  font-size: 14px;
+  line-height: 1.85;
+  color: #637088;
+}
+.lesson-reading-guide {
+  border-top: 1px solid #e6ebf3;
+  margin-top: 20px;
+  padding-top: 15px;
+}
+.step-observation,
+.step-explanation {
+  margin-top: 16px;
+  padding: 13px 17px;
+  border-left: 3px solid #93b9a3;
+}
+.step-observation {
+  background: #f4f9f6;
+}
+.step-explanation {
+  border-left-color: #b7c9eb;
+}
+.step-observation h4,
+.step-explanation h4 {
+  margin: 0 0 5px;
+  font-size: 13px;
+  line-height: 1.6;
+  font-weight: 600;
+  color: #286146;
+}
+.step-explanation h4 {
+  color: #365789;
+}
+.step-observation p,
+.step-explanation p {
+  margin: 0;
+}
+.lesson-recap {
+  border-top: 2px solid #dce6f6;
+  padding-top: 28px;
+}
 .practice-title {
   display: flex;
   align-items: flex-start;

+ 1 - 1
app/layout.tsx

@@ -6,7 +6,7 @@ const mono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] });
 export const metadata: Metadata = {
   title: '术核 AI · 后端工程课件',
   description:
-    '从 API 服务、数据库、异步任务和 Redis 到 Docker、Linux 部署与线上排障的生产系统实战课件。',
+    '用轻量电商商品服务串联 API、表结构设计、数据库、异步任务、Redis、Docker、Linux 与线上排障,通过图解学习后端工程及分布式系统设计,最后用逐模块复盘与自测检验掌握程度。',
   icons: { icon: '/brand/shuh-favicon.ico' },
 };
 export default function RootLayout({

+ 39 - 1
app/lesson-types.ts

@@ -1,13 +1,21 @@
-export type Lesson = {
+export type TeachingLesson = {
+  kind?: 'practical' | 'theory';
   id: string;
   group: string;
   title: string;
   summary: string;
+  intro: {
+    scenario: string;
+    startingPoint: string[];
+    outcome: string;
+  };
   objectives: string[];
   concepts: { title: string; body: string }[];
   steps: {
     title: string;
     body: string;
+    expected: string;
+    explanation: string;
     code?: string;
     lang?: string;
     filename?: string;
@@ -17,6 +25,11 @@ export type Lesson = {
   exercise: { task: string; hint: string; answer: string };
   refs: { label: string; url: string }[];
   demo?: 'http' | 'async' | 'protocol';
+  interview?: {
+    question: string;
+    answer: string;
+    followUps: string[];
+  };
   production?: {
     scenario: string;
     decisions: string[];
@@ -24,6 +37,31 @@ export type Lesson = {
     acceptance: string[];
   };
 };
+export type ReviewItem = {
+  id: string;
+  title: string;
+  prompt: string;
+  checkpoints: string[];
+  misconception: string;
+  revisit: { lessonId: string; label: string }[];
+};
+export type ReviewLesson = {
+  kind: 'review';
+  id: string;
+  group: string;
+  title: string;
+  summary: string;
+  sourceModule: string;
+  intro: {
+    scenario: string;
+    startingPoint: string[];
+    outcome: string;
+  };
+  questions: ReviewItem[];
+  challenges: ReviewItem[];
+  mastery: { id: string; text: string; evidence: string }[];
+};
+export type Lesson = TeachingLesson | ReviewLesson;
 export type Video = {
   type: 'file' | 'link';
   url: string;

+ 1945 - 1606
app/lessons.json

@@ -1,1606 +1,1945 @@
-[
-  {
-    "id": "01",
-    "group": "起步与 HTTP",
-    "title": "启动第一个 FastAPI 服务",
-    "summary": "建立 api-lab 项目,让 /health 返回 JSON,并通过交互文档发出第一次请求。",
-    "objectives": [
-      "为项目创建独立的 Python 环境",
-      "解释 main:app、端口与 --reload 的作用",
-      "分别在浏览器和 /docs 验证同一个接口"
-    ],
-    "concepts": [
-      {
-        "title": "服务进程与路由函数",
-        "body": "普通脚本运行结束后会退出。API 服务由 Uvicorn 持续监听本机端口,收到 HTTP 请求后调用 FastAPI 中匹配的方法与路径。@app.get('/health') 注册的是 GET /health;函数的返回值会被序列化为 JSON 响应。"
-      },
-      {
-        "title": "虚拟环境决定从哪里导入依赖",
-        "body": "同一台电脑可以有多个 Python。虚拟环境把当前项目的解释器入口和第三方包放在 .venv 中。用 python -m pip 安装,能使 pip 与当前 python 指向同一环境;编辑器也应选择 .venv 中的解释器。"
-      },
-      {
-        "title": "开发服务器的几个参数",
-        "body": "main:app 表示从当前目录的 main.py 导入 app 对象。127.0.0.1 是本机地址,8000 是默认端口。--reload 会在代码变化后重启开发进程,重启也会清空之后课程中的内存数据。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "建立目录和虚拟环境",
-        "body": "准备 Python 3.11 或更新版本。以下两组命令按系统选择一组。激活只影响当前终端;以后重新打开终端时,要先切换到 api-lab 再激活。",
-        "lang": "bash",
-        "code": "# Windows PowerShell\nmkdir api-lab\ncd api-lab\npy -3 -m venv .venv\n.\\.venv\\Scripts\\Activate.ps1\n\n# macOS / Linux\nmkdir api-lab\ncd api-lab\npython3 -m venv .venv\nsource .venv/bin/activate\n\n# 激活后检查解释器路径,结果应包含 api-lab/.venv\npython -c \"import sys; print(sys.executable)\""
-      },
-      {
-        "title": "安装依赖并记录环境",
-        "body": "创建 requirements.txt,写入下面三行。然后在激活后的终端运行 python -m pip install -r requirements.txt。本模块使用 Pydantic v2 的 model_dump() 写法。安装成功后运行 python -m pip freeze > requirements-lock.txt,保留当前环境实际使用的版本;另一个环境可从这个锁定文件安装。 如果直接运行下载包,请使用包内已经锁定版本的 requirements.txt。",
-        "filename": "requirements.txt",
-        "lang": "bash",
-        "code": "fastapi>=0.115,<1\nuvicorn[standard]>=0.30,<1\npydantic>=2,<3"
-      },
-      {
-        "title": "编写健康检查接口",
-        "body": "在 api-lab 根目录创建 main.py。FastAPI 实例只创建一次。路由装饰器紧接在函数定义上方,返回值使用 Python 字典,JSON 编码由框架完成。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from fastapi import FastAPI\n\napp = FastAPI(title=\"API Lab\", version=\"0.1.0\")\n\n@app.get(\"/health\", tags=[\"system\"])\ndef health():\n    return {\"status\": \"ok\"}"
-      },
-      {
-        "title": "启动服务并观察终端",
-        "body": "在 main.py 所在目录运行以下命令。终端保持运行状态是正常现象;看到 Application startup complete 后再访问浏览器。服务运行期间用另一个终端执行请求,停止服务使用 Ctrl+C。",
-        "lang": "bash",
-        "code": "python -m uvicorn main:app --reload\n# 默认访问地址:http://127.0.0.1:8000\n# 8000 被占用时,可加 --port 8001"
-      },
-      {
-        "title": "通过浏览器和交互文档验收",
-        "body": "先打开 http://127.0.0.1:8000/health,确认显示 {\"status\":\"ok\"}。再打开 /docs,展开 GET /health → Try it out → Execute,查看状态码、响应头和响应体。最后把 main.py 中的 ok 改为 ready 并保存,观察终端重载日志和新响应,完成后改回 ok。",
-        "lang": "http",
-        "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}"
-      }
-    ],
-    "checks": [
-      "终端的 sys.executable 路径包含 api-lab/.venv。",
-      "GET /health 返回 200 和 {\"status\":\"ok\"};访问未定义的 / 返回 404,这不表示服务未启动。",
-      "/docs 中出现 GET /health,点击 Execute 后能看到真实响应。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "ModuleNotFoundError: No module named 'fastapi'",
-        "cause": "安装依赖和启动服务使用了不同解释器。",
-        "fix": "激活 .venv,检查 sys.executable,再执行 python -m pip install -r requirements.txt。"
-      },
-      {
-        "symptom": "Could not import module 'main'",
-        "cause": "当前目录没有 main.py,或者文件中存在语法错误。",
-        "fix": "切换到 api-lab 根目录,确认文件名为 main.py,阅读终端中最早出现的 Python 错误。"
-      },
-      {
-        "symptom": "PowerShell 阻止 Activate.ps1 运行",
-        "cause": "当前执行策略限制脚本运行。",
-        "fix": "无需修改全局策略,可直接使用 .\\.venv\\Scripts\\python.exe -m pip install -r requirements.txt,以及 .\\.venv\\Scripts\\python.exe -m uvicorn main:app --reload。"
-      }
-    ],
-    "exercise": {
-      "task": "添加 GET /version,返回 {\"service\":\"api-lab\",\"version\":\"0.1.0\"},并在 /docs 中执行一次。",
-      "hint": "保留已有 app,在另一个函数上方注册新的 GET 路由。",
-      "answer": "在 main.py 末尾添加:\n@app.get('/version')\ndef version():\n    return {'service': 'api-lab', 'version': '0.1.0'}\n\n保存后访问 /version,应返回 200;刷新 /docs 后应同时看到 /health 与 /version。"
-    },
-    "refs": [
-      {
-        "label": "Python:venv",
-        "url": "https://docs.python.org/3/library/venv.html"
-      },
-      {
-        "label": "FastAPI:First Steps",
-        "url": "https://fastapi.tiangolo.com/tutorial/first-steps/"
-      },
-      {
-        "label": "FastAPI:手动运行服务器",
-        "url": "https://fastapi.tiangolo.com/deployment/manually/"
-      }
-    ]
-  },
-  {
-    "id": "02",
-    "group": "起步与 HTTP",
-    "title": "读懂一次 HTTP 请求与响应",
-    "summary": "拆开方法、路径、查询参数、请求头和请求体,亲手发送 GET 与 POST。",
-    "demo": "http",
-    "objectives": [
-      "在请求中指出方法、URL、请求头和请求体的位置",
-      "区分 Content-Type、Accept 与 HTTP 状态码",
-      "用 /docs 和命令行复现同一条请求"
-    ],
-    "concepts": [
-      {
-        "title": "请求由几个独立部分组成",
-        "body": "以 POST /notes?source=lesson 为例:POST 是方法,/notes 是路径,source=lesson 是查询参数。请求头携带内容类型等元信息,请求体携带笔记数据。下面的 HTTP/1.1 文本用于辨认字段;浏览器也可能使用其他 HTTP 版本,业务含义一致。"
-      },
-      {
-        "title": "Content-Type 描述实际内容",
-        "body": "请求头 Content-Type: application/json 表示请求体按 JSON 编码;Accept: application/json 表示客户端期望的响应格式。服务器仍按接口定义产生响应,并非只改 Accept 就会自动得到另一种格式。JSON 键和字符串使用双引号,布尔值使用 true / false。"
-      },
-      {
-        "title": "响应状态与业务字段分开读取",
-        "body": "响应先给出 HTTP 状态码,再给出响应头和响应体。200 表示本次 HTTP 操作成功,422 表示请求数据未通过当前接口校验。把 {\"code\":404} 放在一个 200 响应里,不会把 HTTP 状态变成 404。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "观察最小 GET 请求",
-        "body": "保持第 01 课服务运行。在 /docs 执行 /health,同时查看本页的请求拆解演示。把方法、路径、Host 和响应体分别定位;GET /health 不需要请求体。",
-        "lang": "http",
-        "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}"
-      },
-      {
-        "title": "添加临时的 POST 回显接口",
-        "body": "把以下代码追加到 main.py。这个临时接口只读取请求体并返回,不保存笔记,也不宣称创建成功。第 03 课会用带类型的模型替换它。这里先借助 Request 观察 HTTP 数据,异步语法在后续课程集中练习。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from fastapi import Request\n\n@app.post(\"/notes\")\nasync def inspect_note(request: Request):\n    data = await request.json()\n    return {\"received\": data}"
-      },
-      {
-        "title": "准备 JSON 文件并发送 POST",
-        "body": "在 api-lab 中创建 note.json,使用 UTF-8 保存。macOS / Linux 执行 curl -i -X POST http://127.0.0.1:8000/notes -H \"Content-Type: application/json\" --data-binary @note.json;Windows PowerShell 使用 curl.exe,并把 @note.json 写成引号包围的 \"@note.json\"。-i 同时显示响应头。",
-        "filename": "note.json",
-        "lang": "json",
-        "code": "{\n  \"title\": \"first note\",\n  \"content\": \"learn HTTP\",\n  \"pinned\": false\n}"
-      },
-      {
-        "title": "对照原始请求与服务器结果",
-        "body": "命令行应返回 200 和 received 对象。改动 note.json 中的 title 后再次发送,确认响应随之变化。在浏览器开发者工具 Network 中展开 /docs 发出的请求,也能查看 Method、Request Headers、Payload 和 Response。",
-        "lang": "http",
-        "code": "POST /notes HTTP/1.1\nHost: 127.0.0.1:8000\nContent-Type: application/json\n\n{\"title\":\"first note\",\"content\":\"learn HTTP\",\"pinned\":false}\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"received\":{\"title\":\"first note\",\"content\":\"learn HTTP\",\"pinned\":false}}"
-      }
-    ],
-    "checks": [
-      "能说明查询参数属于 URL,请求体位于请求头之后,两者不是同一块数据。",
-      "命令行 POST 返回 200,并在 received 中保留 note.json 的三个字段。",
-      "浏览器直接打开 /notes 会发出 GET;当前只注册了 POST,因此应返回 405 Method Not Allowed。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "PowerShell 提示无法识别 -H 参数",
-        "cause": "使用了旧版 PowerShell 的 curl 别名。",
-        "fix": "明确执行 curl.exe;或者在 /docs 中操作。"
-      },
-      {
-        "symptom": "请求体解析失败",
-        "cause": "把 Python 字典写法当成 JSON,例如使用单引号或 False。",
-        "fix": "使用 note.json 文件,字符串和字段名用双引号,布尔值写 true / false。"
-      },
-      {
-        "symptom": "浏览器地址栏打不开 POST 接口",
-        "cause": "地址栏导航通常发送 GET,没有发送预期的 POST 和 JSON 请求体。",
-        "fix": "使用 curl、/docs 或能够配置 HTTP 方法的客户端。"
-      }
-    ],
-    "exercise": {
-      "task": "把请求体的 title 改成 second note,再发送一次。记录响应的 HTTP 状态码、Content-Type 和 received.title;最后说明 Accept 和 Content-Type 的区别。",
-      "hint": "使用 -i 查看响应头,不要只看最后一行 JSON。",
-      "answer": "状态码为 200;响应 Content-Type 为 application/json;received.title 为 second note。请求 Content-Type 描述这次发送的数据格式,Accept 描述客户端希望接收的格式。"
-    },
-    "refs": [
-      {
-        "label": "RFC 9110:HTTP 消息与内容语义",
-        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-6"
-      },
-      {
-        "label": "RFC 8259:JSON 语法",
-        "url": "https://www.rfc-editor.org/rfc/rfc8259"
-      },
-      {
-        "label": "FastAPI:直接使用 Request",
-        "url": "https://fastapi.tiangolo.com/advanced/using-request-directly/"
-      }
-    ]
-  },
-  {
-    "id": "03",
-    "group": "起步与 HTTP",
-    "title": "从路径、查询参数和 JSON 中接收数据",
-    "summary": "为笔记接口声明参数类型,让 FastAPI 完成解析、默认值填充和基础校验。",
-    "objectives": [
-      "将 note_id 放在路径中,将筛选条件放在查询参数中",
-      "用 Pydantic 模型接收 JSON 请求体",
-      "通过 422 响应定位错误参数"
-    ],
-    "concepts": [
-      {
-        "title": "路径标识对象,查询参数控制读取方式",
-        "body": "/notes/12 中的 12 表示要操作的笔记;/notes?limit=10&pinned=true 中的参数表示读取条件。参数名出现在路由路径中时,FastAPI 将其识别为路径参数。未出现在路径中的简单类型参数通常来自查询字符串。"
-      },
-      {
-        "title": "类型声明参与运行时解析",
-        "body": "HTTP 路径和查询字符串首先都是文本。note_id: int 要求它能解析为整数;limit: int = 10 提供默认值。无法转换的值会在进入路由函数前产生 422 响应,因此函数里不用重复写 int() 和大量类型判断。"
-      },
-      {
-        "title": "请求模型决定必填与默认值",
-        "body": "继承 BaseModel 的 NoteCreate 声明 JSON 对象的结构。title 没有默认值,所以必须发送;content 默认空字符串,pinned 默认 false。str | None 表示允许 null,不自动表示可以省略;若要允许省略,还需写默认值,例如 = None。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "声明创建笔记的输入模型",
-        "body": "在 main.py 的路由定义之前添加模型。删除第 02 课的 inspect_note 路由函数及其装饰器,随后用第 04 步的新 POST /notes 替换,避免同一方法和路径出现多个处理函数。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from pydantic import BaseModel\n\nclass NoteCreate(BaseModel):\n    title: str\n    content: str = \"\"\n    pinned: bool = False"
-      },
-      {
-        "title": "添加路径参数",
-        "body": "添加 GET /notes/{note_id}。本节先回显解析后的编号,暂不查询存储。依次请求 /notes/12 与 /notes/abc,比较状态码;整数解析失败时,错误位置会包含 path 和 note_id。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "@app.get(\"/notes/{note_id}\")\ndef inspect_note_id(note_id: int):\n    return {\"note_id\": note_id}"
-      },
-      {
-        "title": "添加查询参数和范围",
-        "body": "添加 GET /notes。Annotated 把 Python 类型和 FastAPI 的参数规则写在一起。limit 默认为 10,只允许 1 到 100;pinned 未传时为 None,传 false 时是明确的 False,后续筛选中要区分这两种情况。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from typing import Annotated\nfrom fastapi import Query\n\n@app.get(\"/notes\")\ndef inspect_filters(\n    limit: Annotated[int, Query(ge=1, le=100)] = 10,\n    pinned: bool | None = None,\n):\n    return {\"limit\": limit, \"pinned\": pinned}"
-      },
-      {
-        "title": "用类型模型替换原始请求体",
-        "body": "定义新的 POST /notes。在 /docs 中仅发送 {\"title\":\"first note\"},观察缺省字段被补齐。本节仍是参数练习,不写入存储。model_dump() 把 Pydantic 模型转换为 Python 字典。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "@app.post(\"/notes\")\ndef inspect_note_body(note: NoteCreate):\n    return note.model_dump()"
-      },
-      {
-        "title": "建立有效值和无效值对照",
-        "body": "在 /docs 连续完成以下四次调用,记录状态码与响应中的字段位置。缺少 title 的 POST 应在路由运行前失败。下一课会继续收紧 title 的长度与多余字段规则。",
-        "lang": "http",
-        "code": "GET /notes/12\n# 200: {\"note_id\":12}\n\nGET /notes?limit=5&pinned=false\n# 200: {\"limit\":5,\"pinned\":false}\n\nGET /notes?limit=0\n# 422,错误位置包含 [\"query\",\"limit\"]\n\nPOST /notes\nContent-Type: application/json\n\n{}\n# 422,错误位置包含 [\"body\",\"title\"]"
-      }
-    ],
-    "checks": [
-      "GET /notes/12 返回数字 12,/notes/abc 返回 422。",
-      "GET /notes 返回 {\"limit\":10,\"pinned\":null};?pinned=false 返回 false。",
-      "POST /notes 只传 title 时补齐 content 和 pinned;缺少 title 返回 422。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "把 note_id 放进查询字符串,得到列表而不是单条笔记",
-        "cause": "GET /notes?note_id=12 匹配的是 GET /notes。本课列表路由没有声明 note_id,FastAPI 默认忽略这个额外查询参数,因此返回 200 和列表;它不会匹配 /notes/{note_id}。",
-        "fix": "读取单条笔记时改为 GET /notes/12。先检查方法和路径,再检查查询参数;只有确实没有匹配路由或资源不存在时才会得到相应的 404。"
-      },
-      {
-        "symptom": "写了 title: str | None 后,省略 title 仍然报错",
-        "cause": "允许 null 和允许省略是两个条件。",
-        "fix": "如果业务允许省略,显式写默认值 = None;本例 title 应保持必填字符串。"
-      },
-      {
-        "symptom": "修改 POST /notes 后响应还像上一课",
-        "cause": "旧路由没有移除,重复注册了相同方法和路径。",
-        "fix": "搜索 @app.post('/notes'),只保留当前一个定义,保存并确认服务完成重载。"
-      }
-    ],
-    "exercise": {
-      "task": "为 GET /notes 添加 offset 查询参数,默认 0,只允许非负整数,并把解析结果加进响应。验证 offset=3 与 offset=-1。",
-      "hint": "参考 limit 的 Annotated[int, Query(...)],使用 ge=0。",
-      "answer": "在函数参数中加入 offset: Annotated[int, Query(ge=0)] = 0,并在返回字典中加入 'offset': offset。?offset=3 返回 200 且 offset 为 3;?offset=-1 返回 422,loc 包含 query、offset。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:路径参数",
-        "url": "https://fastapi.tiangolo.com/tutorial/path-params/"
-      },
-      {
-        "label": "FastAPI:查询参数",
-        "url": "https://fastapi.tiangolo.com/tutorial/query-params/"
-      },
-      {
-        "label": "FastAPI:请求体",
-        "url": "https://fastapi.tiangolo.com/tutorial/body/"
-      }
-    ]
-  },
-  {
-    "id": "04",
-    "group": "起步与 HTTP",
-    "title": "用 Pydantic 定义输入和输出契约",
-    "summary": "限制标题长度、拒绝拼错的字段,并通过 response_model 控制客户端能看到的数据。",
-    "objectives": [
-      "用 Field 声明范围,用 extra='forbid' 发现字段拼写错误",
-      "分开定义创建输入与接口输出模型",
-      "区分请求校验失败与响应校验失败"
-    ],
-    "concepts": [
-      {
-        "title": "模型把接口规则放到一起",
-        "body": "类型描述数据形状,Field 描述长度或数值约束。本例 title 的长度为 1 到 120。extra='forbid' 会拒绝模型中未声明的字段,能及时发现 pinnd 这样的拼写错误;Pydantic 默认会忽略多余字段,因此需要显式配置。"
-      },
-      {
-        "title": "输入模型与输出模型承担不同职责",
-        "body": "创建时客户端只提交 title、content、pinned;id 由服务端产生。NoteOut 在输入字段之外增加 id。response_model 会校验并筛选普通返回数据,未声明的内部字段不会进入响应;这也会出现在 /docs 的响应结构中。"
-      },
-      {
-        "title": "校验成功不代表全部业务规则成立",
-        "body": "长度为 1 的字符串可能只是一个空格,类型与长度规则不会自动决定标题是否有意义。输入校验失败通常返回 422;如果路由返回的数据不符合输出模型,例如漏掉 id,则属于服务端代码错误,默认返回 500,并在终端记录具体原因。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "收紧 NoteCreate 的输入规则",
-        "body": "用下面定义替换原来的 NoteCreate,放在路由之前。保留其余路由。随后在 /docs 中分别发送空标题和拼错的 pinnd 字段,确认都得到 422。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from pydantic import BaseModel, ConfigDict, Field\n\nclass NoteCreate(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    title: str = Field(min_length=1, max_length=120)\n    content: str = \"\"\n    pinned: bool = False"
-      },
-      {
-        "title": "增加响应模型",
-        "body": "在 NoteCreate 后添加 NoteOut。输出字段单独声明,避免把输入的 extra='forbid' 规则带入本节的输出过滤实验。id 在输出里必填;客户端创建时不能自行指定 id。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "class NoteOut(BaseModel):\n    id: int\n    title: str\n    content: str\n    pinned: bool"
-      },
-      {
-        "title": "用 response_model 约束 POST 响应",
-        "body": "替换 POST /notes 的装饰器和函数。本节的 id=1 仍是演示值,下一阶段才生成真实编号。返回字典故意增加 internal_source,查看 HTTP 响应时该字段应被过滤。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "@app.post(\"/notes\", response_model=NoteOut)\ndef inspect_note_body(note: NoteCreate):\n    return {\n        \"id\": 1,\n        **note.model_dump(),\n        \"internal_source\": \"validation-lesson\",\n    }"
-      },
-      {
-        "title": "分别制造输入与输出错误",
-        "body": "先发送下面的空标题请求,确认 422 的错误位置包含 body、title。再临时删除返回字典中的 id,发送正常请求,观察 500 和终端中的响应校验异常。恢复 id 后再继续;不要把服务端输出错误改成客户端的 422。",
-        "lang": "json",
-        "code": "{\n  \"title\": \"\",\n  \"content\": \"title must not be empty\",\n  \"pinned\": false\n}"
-      },
-      {
-        "title": "检查文档中的契约",
-        "body": "刷新 /docs。展开 POST /notes 的 Schema,检查 title 的 minLength、maxLength,以及 NoteOut 的 id。发送 {\"title\":\"schema check\"} 后应返回下面四个字段。保存一份响应作为后续 CRUD 的验收样本。",
-        "lang": "json",
-        "code": "{\n  \"id\": 1,\n  \"title\": \"schema check\",\n  \"content\": \"\",\n  \"pinned\": false\n}"
-      }
-    ],
-    "checks": [
-      "空 title 或额外 pinnd 字段均返回 422。",
-      "正常创建演示返回 id、title、content、pinned 四个字段,不包含 internal_source。",
-      "故意漏掉输出 id 时出现 500;恢复后为 200,当前尚未设置创建成功的 201。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "空格标题通过了 min_length=1",
-        "cause": "空格也是字符串字符,长度校验不执行去空格。",
-        "fix": "根据业务要求加入 strip_whitespace 或字段校验器,去空格后再检查是否为空。"
-      },
-      {
-        "symptom": "response_model 没有过滤某个自定义响应",
-        "cause": "直接返回 JSONResponse 等 Response 对象时,框架不会按普通返回值继续执行响应模型转换。",
-        "fix": "本节返回字典或模型对象,让 FastAPI 使用 response_model 处理。"
-      },
-      {
-        "symptom": "把模型直接拼到字典时提示类型不匹配",
-        "cause": "Pydantic 模型对象不等于普通字典。",
-        "fix": "需要字典时调用 note.model_dump();不要使用旧教材中的 .dict() 作为本模块标准写法。"
-      }
-    ],
-    "exercise": {
-      "task": "拒绝只有空白的标题,并将 '  first note  ' 保存为 'first note'。保持 120 字符上限。",
-      "hint": "为 title 添加 mode='before' 的 field_validator,先去除字符串两端空白,再交给 Field 长度校验。",
-      "answer": "从 pydantic 导入 field_validator,在 NoteCreate 内添加:\n@field_validator('title', mode='before')\n@classmethod\ndef strip_title(cls, value):\n    return value.strip() if isinstance(value, str) else value\n\n只含空格会变为空字符串并返回 422;'  first note  ' 经解析后为 'first note'。"
-    },
-    "refs": [
-      {
-        "label": "Pydantic:Field 与字段约束",
-        "url": "https://docs.pydantic.dev/latest/concepts/fields/"
-      },
-      {
-        "label": "Pydantic:验证器",
-        "url": "https://docs.pydantic.dev/latest/concepts/validators/"
-      },
-      {
-        "label": "FastAPI:响应模型",
-        "url": "https://fastapi.tiangolo.com/tutorial/response-model/"
-      }
-    ]
-  },
-  {
-    "id": "05",
-    "group": "REST 接口",
-    "title": "设计笔记资源与 HTTP 行为",
-    "summary": "确定集合、单条资源、全量替换和删除的接口约定,再用请求验证方法与状态码。",
-    "objectives": [
-      "用资源路径表达笔记集合和单条笔记",
-      "区分 POST、PUT、PATCH、DELETE 的语义",
-      "解释 200、201、204、404、405、422 与幂等性"
-    ],
-    "concepts": [
-      {
-        "title": "先确定资源,再确定操作",
-        "body": "本项目使用 /notes 表示笔记集合,/notes/{note_id} 表示一条笔记。读取用 GET,创建用 POST,完整替换用 PUT,删除用 DELETE。方法和路径共同确定接口,因此 GET /notes 与 POST /notes 可以共用路径。"
-      },
-      {
-        "title": "PUT 的完整替换需要明确输入规则",
-        "body": "本课程的 PUT 要求 title、content、pinned 全部出现,替换指定笔记可编辑字段,id 保持不变。PATCH 通常表达局部更新,本模块暂不实现。若把创建模型直接用于 PUT,省略 content 会触发默认空字符串,容易被误认为保留旧值。"
-      },
-      {
-        "title": "幂等描述重复请求后的目标状态",
-        "body": "重复相同 PUT,目标笔记应保持相同内容;重复 DELETE,目标笔记都处于不存在状态,第一次可以返回 204,第二次可以返回 404。幂等不要求每次响应完全相同。POST 创建通常每次产生新资源,不能假设自动重复发送不会多建一条。"
-      },
-      {
-        "title": "状态码也是接口契约",
-        "body": "读取与更新成功使用 200,创建成功使用 201,删除成功且无响应体使用 204。找不到资源使用 404,路径存在但方法不支持使用 405,输入校验失败使用 422。4xx、5xx 响应不能只靠 JSON 业务字段表达,HTTP 状态也必须正确。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "写下接口清单",
-        "body": "以下是第 06 课要实现的目标契约。当前服务还处于参数练习阶段,不应把回显结果视为已保存。先在纸上或请求文件中列出方法、路径、输入和成功状态,后续逐项实现。",
-        "filename": "notes-contract.http",
-        "lang": "http",
-        "code": "GET    /notes              # 查询集合,200,响应为数组\nPOST   /notes              # 创建,201,响应包含新 id\nGET    /notes/{note_id}    # 查询单条,200 或 404\nPUT    /notes/{note_id}    # 完整替换,200 或 404\nDELETE /notes/{note_id}    # 删除,204 或 404"
-      },
-      {
-        "title": "用已有服务区分 404 与 405",
-        "body": "在 /docs 执行 GET /health,应返回 200。然后命令行发送 POST /health,应返回 405,并检查 Allow 响应头;再 GET /missing-path,应返回 404。Windows 将下面 curl 改为 curl.exe。这两类错误都与 JSON 字段校验无关。",
-        "lang": "bash",
-        "code": "curl -i http://127.0.0.1:8000/health\ncurl -i -X POST http://127.0.0.1:8000/health\ncurl -i http://127.0.0.1:8000/missing-path"
-      },
-      {
-        "title": "为完整替换定义独立输入",
-        "body": "在 main.py 的模型区添加 NoteReplace。三个字段均无默认值,确保调用方明确提交完整内容。在 Python 终端或单独脚本中使用下面示例验证;此时只定义模型,尚未注册 PUT 路由。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "class NoteReplace(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    title: str = Field(min_length=1, max_length=120)\n    content: str\n    pinned: bool\n\n# 在独立 Python 终端中测试:\n# from main import NoteReplace\n# NoteReplace(title=\"updated\", content=\"\", pinned=False)  # 通过\n# NoteReplace(title=\"updated\")  # ValidationError:缺少两项"
-      },
-      {
-        "title": "准备替换与删除的验收请求",
-        "body": "把完整请求体保存为 replace.json。下一课先创建一条笔记,记录真实 id,再执行两次同样 PUT 和两次 DELETE。不要写死假定 id 永远为 1;服务重载会影响当前内存。204 响应后不要继续解析 JSON。",
-        "filename": "replace.json",
-        "lang": "json",
-        "code": "{\n  \"title\": \"updated note\",\n  \"content\": \"replace all editable fields\",\n  \"pinned\": true\n}"
-      }
-    ],
-    "checks": [
-      "GET /health 是 200;POST /health 是 405;不存在的路径是 404。",
-      "NoteReplace 缺少 content 或 pinned 时验证失败,空 content 和 false 本身都合法。",
-      "能解释 DELETE 第二次返回 404 仍然满足幂等,以及 204 为什么不能带 {\"ok\":true}。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "PUT 请求只传 title,意外把 content 清空",
-        "cause": "复用了带默认值的创建模型,未明确全量替换规则。",
-        "fix": "使用所有可编辑字段均必填的 NoteReplace;如果需要局部更新,应另行设计 PATCH。"
-      },
-      {
-        "symptom": "客户端对成功 DELETE 调用 response.json() 后报错",
-        "cause": "204 响应没有响应体。",
-        "fix": "先判断状态码;遇到 204 直接按成功处理,不执行 JSON 解码。"
-      },
-      {
-        "symptom": "网络超时后重复 POST,出现两条笔记",
-        "cause": "客户端没收到响应,并不等于服务器没创建资源。",
-        "fix": "本地实验先查询列表核对;生产创建接口需根据业务设计幂等键等重复提交控制,不能直接假定 POST 可安全重试。"
-      }
-    ],
-    "exercise": {
-      "task": "评审三个接口:GET /delete-note?id=1、PUT /notes/1 只接收 pinned、DELETE /notes/1 返回 200 和 {\"deleted\":true}。给出本课程约定下的修改方案,并说明第三种设计是否违反 HTTP。",
-      "hint": "分清 HTTP 协议允许的行为与课程选择的统一接口约定。",
-      "answer": "第一种改为 DELETE /notes/1,GET 不用于用户请求的数据删除。第二种若只做局部修改应定义 PATCH,或者要求 PUT 传齐 title/content/pinned。第三种返回 200 和结果体在 HTTP 中可以成立;本课程统一选择 204 空体,因此需同时调整状态码和响应内容。"
-    },
-    "refs": [
-      {
-        "label": "RFC 9110:请求方法",
-        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9"
-      },
-      {
-        "label": "RFC 9110:204 No Content",
-        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.5"
-      },
-      {
-        "label": "FastAPI:更新请求体",
-        "url": "https://fastapi.tiangolo.com/tutorial/body-updates/"
-      }
-    ]
-  },
-  {
-    "id": "06",
-    "group": "REST 接口",
-    "title": "完成内存版笔记 CRUD",
-    "summary": "把接口契约变成可运行程序,按创建、查询、替换、删除的顺序完成一次完整操作。",
-    "objectives": [
-      "用字典保存笔记并生成不重复的进程内编号",
-      "实现带校验与响应模型的笔记 REST 接口",
-      "按真实创建结果串联后续请求并验证失败路径"
-    ],
-    "concepts": [
-      {
-        "title": "请求之间需要共享状态",
-        "body": "局部变量在函数调用结束后不用于保存下一次请求的数据。这里用模块级 notes 字典,键为整数 id,值为笔记字段。它只存在于当前 Python 进程内;重启、重载都会清空,多个 worker 也不会共享这份字典。"
-      },
-      {
-        "title": "编号与列表位置不是同一个概念",
-        "body": "使用单独递增的 next_id 分配编号,删除笔记后不回退。不能用 len(notes)+1 生成 id,否则删掉中间一条后可能复用仍存在的编号。客户端应从创建响应读取 id,再构造单条资源路径。"
-      },
-      {
-        "title": "演示存储的运行条件",
-        "body": "本节路由使用 async def,内部没有 await,字典读写在一次事件循环执行段中完成。以单个 Uvicorn worker 运行本地实验;不要在更新共享状态的中间加入等待操作。异步函数与等待的细节将在后面的并发课程中单独演示。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "重建 main.py 的公共部分",
-        "body": "先备份前几课的实验文件,再将 main.py 替换为本步骤代码,并按顺序追加第 2、3、4 步。不要同时保留临时回显路由。全部代码完成后再启动;本节不含 /version 等附加练习接口。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from typing import Annotated\n\nfrom fastapi import FastAPI, HTTPException, Query, Response\nfrom pydantic import BaseModel, ConfigDict, Field\n\napp = FastAPI(title=\"API Lab\")\n\nclass NoteCreate(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    title: str = Field(min_length=1, max_length=120)\n    content: str = \"\"\n    pinned: bool = False\n\nclass NoteReplace(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    title: str = Field(min_length=1, max_length=120)\n    content: str\n    pinned: bool\n\nclass NoteOut(BaseModel):\n    id: int\n    title: str\n    content: str\n    pinned: bool\n\nnotes: dict[int, dict] = {}\nnext_id = 1\n\n@app.get(\"/health\", tags=[\"system\"])\nasync def health():\n    return {\"status\": \"ok\"}"
-      },
-      {
-        "title": "实现列表与创建",
-        "body": "追加两个集合路由。pinned is not None 保证 false 也会触发筛选。创建成功后返回 201,并通过 Location 响应头给出新资源路径。next_id 在当前进程递增,保存时写入所有输入字段。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "@app.get(\"/notes\", response_model=list[NoteOut], tags=[\"notes\"])\nasync def list_notes(\n    limit: Annotated[int, Query(ge=1, le=100)] = 10,\n    pinned: bool | None = None,\n):\n    rows = list(notes.values())\n    if pinned is not None:\n        rows = [row for row in rows if row[\"pinned\"] == pinned]\n    return rows[:limit]\n\n@app.post(\"/notes\", response_model=NoteOut, status_code=201, tags=[\"notes\"])\nasync def create_note(note: NoteCreate, response: Response):\n    global next_id\n    note_id = next_id\n    next_id += 1\n    row = {\"id\": note_id, **note.model_dump()}\n    notes[note_id] = row\n    response.headers[\"Location\"] = f\"/notes/{note_id}\"\n    return row"
-      },
-      {
-        "title": "实现单条读取与全量替换",
-        "body": "先定义统一查找函数。不存在时 raise HTTPException 会立即中断本次请求,下一课再统一其错误格式。PUT 创建全新的字段字典并保留路径 id,不把旧 content 或 pinned 合并进去。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "def require_note(note_id: int) -> dict:\n    if note_id not in notes:\n        raise HTTPException(status_code=404, detail=\"笔记不存在\")\n    return notes[note_id]\n\n@app.get(\"/notes/{note_id}\", response_model=NoteOut, tags=[\"notes\"])\nasync def get_note(note_id: int):\n    return require_note(note_id)\n\n@app.put(\"/notes/{note_id}\", response_model=NoteOut, tags=[\"notes\"])\nasync def replace_note(note_id: int, note: NoteReplace):\n    require_note(note_id)\n    row = {\"id\": note_id, **note.model_dump()}\n    notes[note_id] = row\n    return row"
-      },
-      {
-        "title": "实现无响应体的删除",
-        "body": "删除前先检查是否存在;返回 Response(status_code=204) 明确表示空响应。全部代码写好后运行 python -m uvicorn main:app --reload,打开 /docs,确认一个健康检查加五个笔记操作,共六个路由方法。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "@app.delete(\"/notes/{note_id}\", status_code=204, tags=[\"notes\"])\nasync def delete_note(note_id: int):\n    require_note(note_id)\n    del notes[note_id]\n    return Response(status_code=204)"
-      },
-      {
-        "title": "按顺序完成一轮 CRUD",
-        "body": "使用 /docs:先 GET /notes 确認 [];POST 下列对象并记下实际 id;GET /notes/{id};PUT 提交 title='updated'、content=''、pinned=true;再 GET 确认内容已替换;DELETE 后再次 GET,确认 404。操作过程中不要修改代码触发重载。最后重新创建一条,检查编号没有因删除而回退。",
-        "lang": "json",
-        "code": "{\n  \"title\": \"first CRUD\",\n  \"content\": \"create, read, replace, delete\",\n  \"pinned\": false\n}"
-      }
-    ],
-    "checks": [
-      "空列表返回 200 和 [];创建返回 201,响应含整数 id,Location 指向 /notes/{id}。",
-      "PUT 传齐字段时返回 200,id 保持不变;仅传 title 时返回 422。",
-      "删除返回 204 且响应体为空;再 GET 或 DELETE 相同 id 均返回 404。",
-      "创建两条并删除其中一条后,再创建不会覆盖另一条;服务重启后列表重新为空。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "保存代码后刚创建的笔记找不到了",
-        "cause": "--reload 重启了服务进程,内存字典被重新初始化。",
-        "fix": "完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。"
-      },
-      {
-        "symptom": "筛选 pinned=false 返回全部数据",
-        "cause": "使用 if pinned 判断是否筛选,False 被当成未提供条件。",
-        "fix": "使用 if pinned is not None,再比较字段的布尔值。"
-      },
-      {
-        "symptom": "删除一条后,新笔记覆盖了旧笔记",
-        "cause": "用 len(notes)+1 计算编号导致重复。",
-        "fix": "独立维护递增 next_id,不因删除回退;本地练习保持单进程运行。"
-      }
-    ],
-    "exercise": {
-      "task": "在列表接口加入 offset>=0,先按 pinned 筛选,再返回 rows[offset:offset+limit]。创建三条笔记,第二条和第三条设为 pinned=true,验证 pinned=true&offset=1&limit=1 只返回第三条。",
-      "hint": "分页应该作用在筛选后的结果上;把切片放在筛选后,limit 和 offset 均添加参数约束。",
-      "answer": "添加 offset: Annotated[int, Query(ge=0)] = 0;保持 pinned 筛选顺序;把 return rows[:limit] 改为 return rows[offset:offset + limit]。按题目创建顺序,返回数组长度为 1,元素为第三条笔记;offset=-1 返回 422。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:响应状态码",
-        "url": "https://fastapi.tiangolo.com/tutorial/response-status-code/"
-      },
-      {
-        "label": "FastAPI:直接返回 Response",
-        "url": "https://fastapi.tiangolo.com/advanced/response-directly/"
-      },
-      {
-        "label": "FastAPI:处理错误",
-        "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
-      }
-    ]
-  },
-  {
-    "id": "07",
-    "group": "REST 接口",
-    "title": "返回可定位、格式一致的错误",
-    "summary": "用 HTTPException 表达已知失败,统一 404、405 与 422 的响应结构,并保留字段位置。",
-    "objectives": [
-      "在业务失败时抛出 HTTPException",
-      "为 HTTPException 和 RequestValidationError 注册处理器",
-      "保持 HTTP 状态码,同时返回稳定的错误结构"
-    ],
-    "concepts": [
-      {
-        "title": "已知失败应在对应位置结束请求",
-        "body": "查不到笔记是可以预期的业务结果,用 raise HTTPException(status_code=404, detail='笔记不存在') 结束当前处理。return HTTPException(...) 只是在返回一个对象,不能表达相同控制流程。未知编程错误需要修复,不能一律包装成 200。"
-      },
-      {
-        "title": "统一外层结构,保留不同错误的细节",
-        "body": "本项目约定 {error:{code,message,details}}。HTTP 错误使用 code='http_error';请求校验使用 code='validation_error'。HTTP 状态码仍保留 404、405、422 等差异。details 中只返回定位所需的字段路径、错误类型和说明,不回显整个请求体。"
-      },
-      {
-        "title": "校验失败可能发生在路由执行之前",
-        "body": "参数解析失败会抛出 RequestValidationError,不会进入路由里的 try/except。因此在应用级处理器统一处理。注册 Starlette 的 HTTPException 基类还能覆盖路由不存在和方法不支持等框架错误;处理时保留 Allow 等原有响应头。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "确认已有业务错误入口",
-        "body": "保留第 06 课代码,找到 require_note。确认它使用 raise 而非 return。请求一个不存在的 id,此时默认响应通常为 {\"detail\":\"笔记不存在\"},接下来统一其外层结构。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "def require_note(note_id: int) -> dict:\n    if note_id not in notes:\n        raise HTTPException(status_code=404, detail=\"笔记不存在\")\n    return notes[note_id]"
-      },
-      {
-        "title": "统一 HTTP 错误处理",
-        "body": "在 main.py 中添加导入与处理器。这里演示业务 detail 为字符串;若后续传入字典,应单独约定字段映射,不把任意内部对象直接返回。保留 exc.headers,确保例如 405 的 Allow 头仍然存在。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\n@app.exception_handler(StarletteHTTPException)\nasync def handle_http_error(request: Request, exc: StarletteHTTPException):\n    return JSONResponse(\n        status_code=exc.status_code,\n        headers=exc.headers,\n        content={\n            \"error\": {\n                \"code\": \"http_error\",\n                \"message\": str(exc.detail),\n                \"details\": [],\n            }\n        },\n    )"
-      },
-      {
-        "title": "统一 422 并保留字段位置",
-        "body": "添加请求校验处理器。不要直接把 exc.errors() 中的所有对象交给 JSON 序列化;自定义校验器的 ctx 可能包含异常对象。这里只提取 JSON 可序列化的 loc、type、msg,也避免在响应中回显原始输入。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from fastapi.exceptions import RequestValidationError\n\n@app.exception_handler(RequestValidationError)\nasync def handle_validation_error(request: Request, exc: RequestValidationError):\n    details = [\n        {\n            \"loc\": list(error[\"loc\"]),\n            \"type\": error[\"type\"],\n            \"msg\": error[\"msg\"],\n        }\n        for error in exc.errors()\n    ]\n    return JSONResponse(\n        status_code=422,\n        content={\n            \"error\": {\n                \"code\": \"validation_error\",\n                \"message\": \"请求参数不符合接口要求\",\n                \"details\": details,\n            }\n        },\n    )"
-      },
-      {
-        "title": "用三类失败验证处理器",
-        "body": "先 GET /notes/999999,确认 404 且 error.code='http_error'。再 GET /notes/abc 和 POST /notes 的空对象,确认 422;前者定位 path.note_id,后者定位 body.title。最后 POST /health,检查 405、http_error 以及 Allow 头。具体英文 msg 可能随依赖版本变化,客户端不要解析这段文本作业务判断。",
-        "lang": "json",
-        "code": "{\n  \"error\": {\n    \"code\": \"validation_error\",\n    \"message\": \"请求参数不符合接口要求\",\n    \"details\": [\n      {\n        \"loc\": [\"body\", \"title\"],\n        \"type\": \"missing\",\n        \"msg\": \"Field required\"\n      }\n    ]\n  }\n}"
-      }
-    ],
-    "checks": [
-      "不存在的笔记返回 404、http_error 和“笔记不存在”。",
-      "非法 note_id 与缺失 title 都返回 422、validation_error,details.loc 能指出不同位置。",
-      "POST /health 仍返回 405,Allow 响应头被保留。",
-      "成功的笔记响应不被包在 error 字段中;204 删除仍然没有响应体。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "错误响应有 message,但 HTTP 状态一直是 200",
-        "cause": "只返回了错误字典,没有设置 JSONResponse 的 status_code。",
-        "fix": "在异常处理器中显式设置对应状态,HTTP 错误使用 exc.status_code。"
-      },
-      {
-        "symptom": "注册 HTTPException 处理器后,未知路径仍是默认格式",
-        "cause": "只处理 FastAPI 的异常子类,没有覆盖框架抛出的 Starlette 基类异常。",
-        "fix": "导入并为 StarletteHTTPException 注册处理器,路由仍可抛出 FastAPI 的 HTTPException。"
-      },
-      {
-        "symptom": "自定义标题校验失败后出现 JSON 序列化异常",
-        "cause": "把 exc.errors() 的 ctx 异常对象直接放进 JSONResponse。",
-        "fix": "只提取 loc、type、msg 等必要字段,不直接返回任意异常对象或原始请求内容。"
-      }
-    ],
-    "exercise": {
-      "task": "对 GET /notes?limit=0、GET /notes/abc、POST /notes 的 {} 逐一记录 status、error.code 和第一条 details.loc,写出客户端应如何定位表单或请求参数错误。",
-      "hint": "三次都是 422,但错误位置的来源不同:query、path、body。",
-      "answer": "三次 status 都是 422,code 都是 validation_error。loc 分别为 ['query','limit']、['path','note_id']、['body','title']。客户端先按 status/code 判断错误类别,再用 loc 映射对应输入位置;msg 用于显示,不作为稳定的判断条件。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:覆盖异常处理器",
-        "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
-      },
-      {
-        "label": "Starlette:Exceptions",
-        "url": "https://github.com/Kludex/starlette/blob/main/docs/exceptions.md"
-      },
-      {
-        "label": "Pydantic:错误处理",
-        "url": "https://docs.pydantic.dev/latest/errors/errors/"
-      }
-    ]
-  },
-  {
-    "id": "08",
-    "group": "REST 接口",
-    "title": "复用依赖与拆分路由文件",
-    "summary": "用 X-API-Key 练习依赖执行顺序,把 /private 放入独立 APIRouter,并通过环境变量配置课堂密钥。",
-    "objectives": [
-      "用 Depends 在路由执行前完成可复用校验",
-      "从请求头读取 X-API-Key,从环境变量读取预期值",
-      "用 APIRouter 拆分文件并注册到同一个应用"
-    ],
-    "concepts": [
-      {
-        "title": "Depends 声明请求处理的前置步骤",
-        "body": "FastAPI 收到请求后先解析依赖需要的参数,执行依赖函数,再进入路由。依赖抛出 HTTPException 时,路由不会执行。同一套校验可以挂在多个接口或整个 APIRouter 上,避免每个路由重复读取请求头。"
-      },
-      {
-        "title": "Header 与查询参数的来源不同",
-        "body": "x_api_key: str 这种普通参数默认会作为查询参数。使用 Header(alias='X-API-Key') 才从请求头读取。这里给缺失值默认 None,再由依赖统一返回课堂约定的 401;缺少请求头不会先变成默认必填参数的 422。401 响应携带 WWW-Authenticate: APIKey,说明本实验使用的凭据类型。"
-      },
-      {
-        "title": "配置值与身份系统分开理解",
-        "body": "DEMO_API_KEY 从启动终端的环境变量读取,网页教程中的 classroom-key 仅用于本地实验。这个共享字符串只演示请求校验,不能区分用户,也没有密钥轮换或权限管理。本节只保护 /private,公开的笔记接口用于本地练习。"
-      },
-      {
-        "title": "APIRouter 负责组织,不会自动启动服务",
-        "body": "独立文件创建 router 并注册其路由,主入口再 app.include_router(router)。依赖模块不导入 main,避免循环导入。多个文件最终仍组成同一个 FastAPI 应用,共享第 07 课注册的异常处理器。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "建立依赖函数文件",
-        "body": "在 api-lab 根目录新增 dependencies.py,不修改前面的笔记接口。用 secrets.compare_digest 比较字符串。未配置环境变量返回 503,缺失或错误请求头返回 401,这两种失败帮助区分服务配置和调用参数。",
-        "filename": "dependencies.py",
-        "lang": "python",
-        "code": "import os\nimport secrets\nfrom typing import Annotated\n\nfrom fastapi import Header, HTTPException\n\ndef require_demo_key(\n    x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n) -> None:\n    expected = os.getenv(\"DEMO_API_KEY\")\n    if not expected:\n        raise HTTPException(status_code=503, detail=\"服务未配置 DEMO_API_KEY\")\n    if x_api_key is None or not secrets.compare_digest(\n        x_api_key.encode(\"utf-8\"), expected.encode(\"utf-8\")\n    ):\n        raise HTTPException(\n            status_code=401,\n            detail=\"X-API-Key 无效\",\n            headers={\"WWW-Authenticate\": \"APIKey\"},\n        )"
-      },
-      {
-        "title": "创建独立的私有路由",
-        "body": "新增 routers 目录,其中创建空的 __init__.py 和下面的 private.py。把依赖挂在 APIRouter 上,当前文件中的接口都会执行相同校验。装饰器使用 @router.get,路径保持 /private。",
-        "filename": "routers/private.py",
-        "lang": "python",
-        "code": "from fastapi import APIRouter, Depends\n\nfrom dependencies import require_demo_key\n\nrouter = APIRouter(\n    tags=[\"private\"],\n    dependencies=[Depends(require_demo_key)],\n)\n\n@router.get(\"/private\")\ndef read_private():\n    return {\"message\": \"API key accepted\"}"
-      },
-      {
-        "title": "在主入口注册 router",
-        "body": "在 main.py 导入路由模块,并在 app 创建之后调用 include_router。保留原有 /health、笔记路由和两个异常处理器。刷新 /docs,确认出现 private 分组及 X-API-Key 参数;如果没有出现,先检查是否真正注册。",
-        "filename": "main.py",
-        "lang": "python",
-        "code": "from routers import private\n\n# 放在 app = FastAPI(...) 之后\napp.include_router(private.router)\n\n# 目录结构\n# api-lab/\n#   main.py\n#   dependencies.py\n#   routers/\n#     __init__.py\n#     private.py"
-      },
-      {
-        "title": "设置当前终端环境并重新启动",
-        "body": "先 Ctrl+C 停止旧服务,在同一个终端按系统选择一组命令。新启动的进程继承该终端环境;在另一窗口设置变量不会修改正在运行的服务。classroom-key 是公开教学校验值。",
-        "lang": "bash",
-        "code": "# Windows PowerShell\n$env:DEMO_API_KEY = \"classroom-key\"\npython -m uvicorn main:app --reload\n\n# macOS / Linux\nexport DEMO_API_KEY=\"classroom-key\"\npython -m uvicorn main:app --reload"
-      },
-      {
-        "title": "验证缺失、错误与正确密钥",
-        "body": "在 /docs 连续执行 GET /private:不填写头部、填写 wrong-key、填写 classroom-key。前两次返回 401,第三次返回 200。第 07 课错误处理器会把失败转换为统一 error 对象。再访问 /notes,确认该本地练习接口未要求密钥。",
-        "lang": "bash",
-        "code": "# macOS / Linux;Windows PowerShell 使用 curl.exe\ncurl -i http://127.0.0.1:8000/private\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: wrong-key\"\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: classroom-key\""
-      }
-    ],
-    "checks": [
-      "/docs 显示 /private 和 X-API-Key 请求头输入项。",
-      "配置环境变量后,缺失或错误密钥返回 401 和 http_error;正确密钥返回 200 和 {\"message\":\"API key accepted\"}。",
-      "未设置 DEMO_API_KEY 的新服务进程返回 503,而不会因为请求头与配置都为空而通过。",
-      "拆分文件后 /health、原有笔记 CRUD 与错误结构仍然可用。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "已在客户端写 X-API-Key,却显示密钥无效",
-        "cause": "服务读取的头部来源声明不正确,或者客户端把值放到了查询字符串。",
-        "fix": "依赖明确使用 Header(alias='X-API-Key');在 Network 或 curl 输出中核对 Request Headers。"
-      },
-      {
-        "symptom": "环境变量已设置,接口仍返回 503",
-        "cause": "变量设置在另一个终端,或者旧服务进程没有重启。",
-        "fix": "在设置变量的同一个终端停止并重启 Uvicorn。"
-      },
-      {
-        "symptom": "新增 private.py 后 /private 返回 404",
-        "cause": "只创建了 router,没有在主 app 上 include_router,或启动时目录不正确。",
-        "fix": "确认 main.py 注册 private.router,并从 api-lab 根目录启动 main:app。"
-      }
-    ],
-    "exercise": {
-      "task": "在 routers/private.py 中新增 GET /private/status,返回 {\"enabled\":true}。复用 router 上的依赖,分别验证无头部与正确头部。再说明为什么这仍不能判断是哪位学生发出的请求。",
-      "hint": "只添加 @router.get('/private/status') 的函数,不必复制校验代码。",
-      "answer": "添加:\n@router.get('/private/status')\ndef private_status():\n    return {'enabled': True}\n\n无 X-API-Key 返回 401;classroom-key 返回 200 和 {'enabled':true}。两个接口自动复用 router 依赖。所有人使用同一公开密钥,服务只能判断是否匹配,无法识别具体用户。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:依赖注入",
-        "url": "https://fastapi.tiangolo.com/tutorial/dependencies/"
-      },
-      {
-        "label": "FastAPI:Header 参数",
-        "url": "https://fastapi.tiangolo.com/tutorial/header-params/"
-      },
-      {
-        "label": "FastAPI:多文件应用",
-        "url": "https://fastapi.tiangolo.com/tutorial/bigger-applications/"
-      },
-      {
-        "label": "Python:compare_digest",
-        "url": "https://docs.python.org/3/library/secrets.html#secrets.compare_digest"
-      }
-    ]
-  },
-  {
-    "id": "09",
-    "group": "异步与调用",
-    "title": "使用 async / await 处理 I/O 等待",
-    "summary": "用两个并发请求复现阻塞,掌握 async / await 在接口中的实际作用。",
-    "objectives": [
-      "写出不阻塞事件循环的 /slow 接口",
-      "测量串行与并发请求的耗时差异",
-      "判断同步 I/O 应改用异步客户端,还是移交线程"
-    ],
-    "concepts": [
-      {
-        "title": "async def 定义协程函数",
-        "body": "调用后得到可等待对象;await 等待结果,并在操作尚未完成时让出执行机会。仅给函数添加 async 不会把内部的阻塞代码变为异步。"
-      },
-      {
-        "title": "等待与计算分开判断",
-        "body": "网络响应、磁盘访问属于 I/O;大量 Python 计算属于 CPU 工作。异步可以交错等待,本课不通过 async 加速计算,也不在接口里引入后台任务队列。"
-      },
-      {
-        "title": "FastAPI 的执行位置",
-        "body": "FastAPI 调用普通 def 路由时使用线程池;async def 路由在事件循环中执行。你在 async def 内直接调用普通辅助函数时,FastAPI 不会自动把辅助函数移到线程。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "增加一个可测量的等待接口",
-        "body": "开始前,在已激活的虚拟环境中运行 python -m pip install \"httpx>=0.27,<1\",第 09 节的客户端脚本会导入它。下载包的 requirements.txt 已包含此依赖。\n\n在 api-lab 的应用中加入路由。delay 限制在 0–5 秒,避免误输很大数值;运行单个服务进程进行本课实验。",
-        "code": "import asyncio\nfrom fastapi import Query\n\n@app.get(\"/slow\")\nasync def slow(delay: float = Query(default=1, ge=0, le=5)):\n    await asyncio.sleep(delay)\n    return {\"delay\": delay}",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "同时发出两次请求",
-        "body": "新建脚本,保持服务已启动后执行 python concurrent_check.py。先建立一个客户端,再让两次请求同时等待;打印状态码和总耗时。页面演示是浏览器模拟,真实耗时以此脚本为准。",
-        "code": "import asyncio\nfrom time import perf_counter\nimport httpx\n\nasync def main():\n    async with httpx.AsyncClient(base_url=\"http://127.0.0.1:8000\") as client:\n        started = perf_counter()\n        results = await asyncio.gather(\n            client.get(\"/slow\", params={\"delay\": 1}),\n            client.get(\"/slow\", params={\"delay\": 1}),\n        )\n        print([r.status_code for r in results])\n        print(f\"elapsed={perf_counter() - started:.2f}s\")\n\nasyncio.run(main())",
-        "lang": "python",
-        "filename": "concurrent_check.py"
-      },
-      {
-        "title": "复现阻塞,再恢复",
-        "body": "暂时把 await asyncio.sleep(delay) 换成 time.sleep(delay),并 import time。保存后等服务完成重载,再运行相同脚本。单进程时两次等待通常接近 2 秒;恢复 async sleep 后通常接近 1 秒。另开终端访问 /health,观察阻塞时它也要等待。结果会受机器和调度开销影响,不以毫秒误差判断对错。",
-        "code": "curl.exe \"http://127.0.0.1:8000/health\"",
-        "lang": "bash"
-      },
-      {
-        "title": "为无法替换的同步 I/O 选择线程",
-        "body": "优先使用库提供的异步方法;若必须在 async def 路由中调用同步 I/O,用 asyncio.to_thread。此处 slow_sync 用 sleep 模拟同步 I/O,实际替换为该库的函数;线程不是本课 CPU 计算加速方案。",
-        "code": "import time\n\ndef slow_sync(delay: float):\n    time.sleep(delay)\n    return {\"delay\": delay}\n\n# 放在 async def 路由内:\n# result = await asyncio.to_thread(slow_sync, delay)\n# return result",
-        "lang": "python"
-      }
-    ],
-    "checks": [
-      "异步版本两个 delay=1 请求均返回 200,总耗时通常接近 1 秒。",
-      "阻塞版本在单进程下耗时明显增加,恢复后 /health 不再等待 slow 的 sleep 结束。",
-      "/slow?delay=-1 与 /slow?delay=6 都返回 422。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "写了 async def,其他请求仍卡住",
-        "cause": "路由内使用 time.sleep、同步 HTTP 客户端或长时间计算,占住事件循环。",
-        "fix": "定位耗时调用;I/O 改用 await 支持的 API,必要时把同步 I/O 放到线程。"
-      },
-      {
-        "symptom": "两次 await 请求仍耗时约 2 秒",
-        "cause": "先 await 第一次,再创建第二次,代码仍是串行等待。",
-        "fix": "用 asyncio.gather 同时调度两个请求,再比较结果。"
-      },
-      {
-        "symptom": "出现 coroutine was never awaited",
-        "cause": "调用异步函数但没有 await,也没有交给任务调度。",
-        "fix": "在 async 函数内 await 结果;脚本最外层用 asyncio.run(main())。"
-      }
-    ],
-    "exercise": {
-      "task": "把脚本改成同时请求 3 个 delay=1 的 /slow,再增加一次 /health,记录总耗时及各状态码。",
-      "hint": "把第三个 /slow 和 /health 一并放进 gather;不要依次 await。",
-      "answer": "四个请求应全部返回 200。使用非阻塞等待时,总耗时通常仍接近最长的一次等待,即约 1 秒;若接近 3 秒,先检查服务是否恢复了 asyncio.sleep。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:并发与 async / await",
-        "url": "https://fastapi.tiangolo.com/async/"
-      },
-      {
-        "label": "Python:协程、gather 与 to_thread",
-        "url": "https://docs.python.org/3/library/asyncio-task.html"
-      }
-    ],
-    "demo": "async"
-  },
-  {
-    "id": "10",
-    "group": "异步与调用",
-    "title": "使用 HTTPX 调用服务与调试 CORS",
-    "summary": "复用 HTTPX 客户端,处理超时与上游错误,并定位浏览器跨域问题。",
-    "objectives": [
-      "在应用生命周期中创建和关闭 AsyncClient",
-      "区分超时、连接失败和非成功状态码",
-      "说明为什么 curl 正常而浏览器请求失败"
-    ],
-    "concepts": [
-      {
-        "title": "一次请求有两个方向",
-        "body": "浏览器到 FastAPI 是入站请求;FastAPI 到另一个服务是出站请求。调用失败时,先确定错误发生在哪一段。"
-      },
-      {
-        "title": "客户端与连接池",
-        "body": "AsyncClient 可以复用连接。应用启动时创建,退出时关闭;不要在每次高频请求内重新创建客户端。connect、read、write、pool 是不同阶段的超时,read 通常是等待下一块数据的上限,不是请求总时限。"
-      },
-      {
-        "title": "CORS 由浏览器检查",
-        "body": "Origin 由协议、主机和端口组成。http://localhost:5500 与 http://127.0.0.1:5500 是两个来源;curl 和服务端 HTTPX 不执行浏览器的 CORS 检查。CORS 也不替代 API 认证。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "把客户端交给 lifespan 管理",
-        "body": "安装 httpx。把现有 FastAPI() 初始化改为使用 lifespan;若应用已有 lifespan,把下面的客户端管理合并进去,不要再创建第二个 app。上游使用本机已完成的 /slow,让超时实验不依赖公网。",
-        "code": "from contextlib import asynccontextmanager\nimport httpx\nfrom fastapi import FastAPI\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    timeout = httpx.Timeout(3.0, connect=1.0)\n    async with httpx.AsyncClient(\n        base_url=\"http://127.0.0.1:8000\", timeout=timeout\n    ) as client:\n        app.state.http = client\n        yield\n\napp = FastAPI(lifespan=lifespan)",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "把出站失败转换为可判断的响应",
-        "body": "增加 /upstream-demo。默认 delay=1 会成功,delay=4 超过 read 超时。这里调用本机接口只是本地实验;正式业务中使用服务方的固定地址,避免让用户任意指定出站 URL。raise_for_status 会把非 2xx 响应转为 HTTPStatusError。",
-        "code": "from fastapi import HTTPException, Query, Request\n\n@app.get(\"/upstream-demo\")\nasync def upstream_demo(\n    request: Request,\n    delay: float = Query(default=1, ge=0, le=5),\n):\n    try:\n        response = await request.app.state.http.get(\n            \"/slow\", params={\"delay\": delay}\n        )\n        response.raise_for_status()\n        return {\"upstream\": response.json()}\n    except httpx.TimeoutException as exc:\n        raise HTTPException(504, \"上游响应超时\") from exc\n    except httpx.HTTPStatusError as exc:\n        raise HTTPException(502, \"上游返回非成功状态\") from exc\n    except httpx.RequestError as exc:\n        raise HTTPException(502, \"无法连接上游服务\") from exc",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "在两个页面来源之间复现 CORS",
-        "body": "API 保持运行在 8000 端口。把下面中间件加入现有 app;已有 CORSMiddleware 时修改原配置,不要重复添加。本实验只允许 http://127.0.0.1:5500。如果使用下载的参考项目,先暂时从 allow_origins 中移除 http://localhost:5500,才能复现后面来源不匹配的情况。",
-        "code": "from fastapi.middleware.cors import CORSMiddleware\n\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"http://127.0.0.1:5500\"],\n    allow_credentials=False,\n    allow_methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\"],\n    allow_headers=[\"Content-Type\", \"X-API-Key\"],\n)",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "建立前端页面并从 5500 访问 API",
-        "body": "在 api-lab 中新建 cors-demo.html,粘贴下面内容。另开一个终端,在 api-lab 目录执行 python -m http.server 5500,保留 API 的 8000 终端。打开 http://127.0.0.1:5500/cors-demo.html 并点击按钮,在 Network 中观察 OPTIONS 预检和 POST;JSON 的 Content-Type 会触发本次跨域预检。再通过 http://localhost:5500/cors-demo.html 打开同一文件,对照来源不匹配时的浏览器错误。此时简单的 GET /health 不一定产生 OPTIONS。",
-        "code": "<!doctype html>\n<html lang=\"zh-CN\">\n<meta charset=\"utf-8\">\n<title>CORS 实验</title>\n<button id=\"send\">向 API 创建笔记</button>\n<pre id=\"result\"></pre>\n<script>\ndocument.querySelector(\"#send\").onclick = async () => {\n  const output = document.querySelector(\"#result\");\n  try {\n    const response = await fetch(\"http://127.0.0.1:8000/notes\", {\n      method: \"POST\",\n      headers: {\"Content-Type\": \"application/json\"},\n      body: JSON.stringify({title: \"CORS 实验\"}),\n    });\n    output.textContent = response.status + \"\\n\" + await response.text();\n  } catch (error) {\n    output.textContent = \"浏览器未能读取响应;检查 Network 和 Console:\" + error.message;\n  }\n};\n</script>\n</html>",
-        "lang": "html",
-        "filename": "cors-demo.html"
-      },
-      {
-        "title": "读预检请求和响应头",
-        "body": "在浏览器 Network 面板查看 Origin、OPTIONS 和 Access-Control-Allow-Origin。curl.exe 可手动查看预检响应,但它不会替浏览器决定是否允许页面读取响应。把来源改为 localhost:5500 后复现不匹配,再恢复准确来源。 macOS / Linux 把 curl.exe 改为 curl。实验完成后,如需同时使用两个前端域名,再把 localhost:5500 加回白名单。",
-        "code": "curl.exe -i -X OPTIONS \"http://127.0.0.1:8000/notes\" -H \"Origin: http://127.0.0.1:5500\" -H \"Access-Control-Request-Method: POST\" -H \"Access-Control-Request-Headers: content-type\"\ncurl.exe -i \"http://127.0.0.1:8000/upstream-demo?delay=4\"",
-        "lang": "bash"
-      }
-    ],
-    "checks": [
-      "/upstream-demo?delay=1 返回 200,包含上游的 delay 字段。",
-      "/upstream-demo?delay=4 返回 504;把上游地址临时改为未监听端口可观察 502,随后恢复。",
-      "允许来源的预检响应包含 Access-Control-Allow-Origin: http://127.0.0.1:5500,浏览器可以读取响应。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "上游返回 404,接口却继续执行正常处理",
-        "cause": "HTTP 请求收到响应不等于业务成功,代码没有检查状态。",
-        "fix": "调用 raise_for_status 并单独处理 HTTPStatusError。"
-      },
-      {
-        "symptom": "curl 成功,网页报 CORS 错误",
-        "cause": "浏览器页面来源与 allow_origins 不匹配,或预检方法、请求头未被允许。",
-        "fix": "逐项核对 Origin、方法和自定义请求头,再看浏览器 Network 中的 OPTIONS 响应。"
-      },
-      {
-        "symptom": "用 timeout=3 就以为整个请求最多 3 秒",
-        "cause": "把阶段性超时误认为总请求截止时间,流式响应每次及时发送数据时可能持续更久。",
-        "fix": "先明确要限制连接、块间等待还是总耗时;本课实验只验证读超时。"
-      }
-    ],
-    "exercise": {
-      "task": "让 /upstream-demo 在等待超过 0.2 秒时返回 504,同时保持直接访问 /slow?delay=1 能正常返回。",
-      "hint": "只覆盖 client.get 的 timeout 参数,保留异步 /slow。",
-      "answer": "给该次请求设置 timeout=httpx.Timeout(0.2, connect=1.0)。请求 /upstream-demo?delay=1 会在等待上游数据时触发超时;直接请求 /slow?delay=1 仍约 1 秒后返回 200。"
-    },
-    "refs": [
-      {
-        "label": "HTTPX:异步客户端",
-        "url": "https://www.python-httpx.org/async/"
-      },
-      {
-        "label": "HTTPX:超时阶段",
-        "url": "https://www.python-httpx.org/advanced/timeouts/"
-      },
-      {
-        "label": "HTTPX:状态码与异常",
-        "url": "https://www.python-httpx.org/quickstart/"
-      },
-      {
-        "label": "FastAPI:lifespan",
-        "url": "https://fastapi.tiangolo.com/advanced/events/"
-      },
-      {
-        "label": "FastAPI:CORS",
-        "url": "https://fastapi.tiangolo.com/tutorial/cors/"
-      }
-    ]
-  },
-  {
-    "id": "11",
-    "group": "流式与实时通信",
-    "title": "完整响应、流式响应、SSE 与 WebSocket",
-    "summary": "从通信方向与消息格式选择接口形态,用同一份文本比较接收过程。",
-    "objectives": [
-      "分清 REST 风格、HTTP 响应方式和 WebSocket 协议",
-      "根据发送方向与数据频率选择实现",
-      "观察首段到达时间与全部完成时间"
-    ],
-    "concepts": [
-      {
-        "title": "REST 不规定必须一次返回全部数据",
-        "body": "REST 主要讨论资源及 HTTP 操作方式。普通 JSON 响应先得到完整结果再返回;HTTP 流式响应逐段发送同一个响应体。流式发送不自动定义每段的业务含义。"
-      },
-      {
-        "title": "SSE 为文本流定义事件边界",
-        "body": "SSE 在 HTTP 响应体中使用 text/event-stream,以空行分隔事件。连接内数据由服务端推向客户端;浏览器可用 EventSource 解析。SSE 是 HTTP 流式响应的一种约定。"
-      },
-      {
-        "title": "WebSocket 保持双向通信",
-        "body": "连接建立后,双方可以独立发送消息,不必每条消息新建 HTTP 请求。业务仍需定义消息类型、结束、错误与连接关闭后的行为。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "先跑一次完整结果接口",
-        "body": "用延时代表逐步产生内容,POST /generate 等待全部内容完成后返回一个 JSON 对象。PromptIn 限制长度,本课使用固定文本拼接,便于观察协议而不引入模型服务。",
-        "code": "import asyncio\nfrom pydantic import BaseModel, Field\n\nclass PromptIn(BaseModel):\n    prompt: str = Field(min_length=1, max_length=200)\n\n@app.post(\"/generate\")\nasync def generate(payload: PromptIn):\n    parts = []\n    for text in [\"收到:\", payload.prompt, \"。处理完成。\"]:\n        await asyncio.sleep(0.4)\n        parts.append(text)\n    return {\"text\": \"\".join(parts)}",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "记录完整响应的等待",
-        "body": "在 /docs 对 POST /generate 输入下面 JSON。结果约在所有步骤完成后出现。区分响应头到达与可用业务内容到达;观察何时收到第一段内容。",
-        "code": "{\"prompt\":\"hello\"}",
-        "lang": "json"
-      },
-      {
-        "title": "对照三条连接过程",
-        "body": "在本页协议演示中依次选择完整 HTTP、SSE、WebSocket:完整响应在完成后显示文本;SSE 逐条显示 token 事件;WebSocket 可在连接保持期间发送新消息。此交互是浏览器模拟;第 12–15 课会用 api-lab 进行真实收发验证。"
-      },
-      {
-        "title": "把场景写成协议选择题",
-        "body": "保存笔记通常使用 HTTP 的 POST /notes;一段处理结果要边产生边显示,可使用 HTTP 流式响应,文本事件采用 SSE;前后端要频繁互发消息,可使用 WebSocket。若只需偶尔查询状态,也可以定期 HTTP 查询,不需要先维护长连接。"
-      }
-    ],
-    "checks": [
-      "能说清 SSE 使用 HTTP,且不是 WebSocket 的别名。",
-      "/generate 返回单个 {text:...} 对象;不会在每个步骤结束时返回一个新 HTTP 响应。",
-      "能为保存笔记、逐步显示文本、双向实时消息分别选择方案并说明原因。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "为了逐字显示文字,直接把所有接口都改成 WebSocket",
-        "cause": "把流式接收和双向消息通信混为一谈。",
-        "fix": "先确定客户端是否需要在同一条连接上持续主动发送;单向文本结果通常先考虑 HTTP 流式响应。"
-      },
-      {
-        "symptom": "把服务器 yield 的次数当成客户端 read 的次数",
-        "cause": "网络、服务器和浏览器可能合并或拆开数据块。",
-        "fix": "依赖协议的事件分隔符解析消息,不依赖网络块边界。"
-      }
-    ],
-    "exercise": {
-      "task": "用户提交一次短文本后只接收生成结果,偶尔点击“停止”。是否必须使用 WebSocket?",
-      "hint": "思考取消 HTTP 请求是否已经满足停止读取的需要。",
-      "answer": "不必须。可以提交 HTTP 请求并读取流式响应,通过取消请求停止读取;服务端同时处理断开并释放资源。如果还需要在同一会话持续双向发送独立消息,再评估 WebSocket。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:StreamingResponse",
-        "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
-      },
-      {
-        "label": "MDN:SSE 事件流",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
-      },
-      {
-        "label": "FastAPI:WebSocket",
-        "url": "https://fastapi.tiangolo.com/advanced/websockets/"
-      }
-    ],
-    "demo": "protocol"
-  },
-  {
-    "id": "12",
-    "group": "流式与实时通信",
-    "title": "用 StreamingResponse 输出 SSE 事件",
-    "summary": "实现有限长度文本流,定义 token / done 事件,并处理客户端断开。",
-    "objectives": [
-      "用异步生成器逐段发送 text/event-stream",
-      "使用 JSON 编码 data 字段并保留空行边界",
-      "区分正常完成、连接断开和流内错误"
-    ],
-    "concepts": [
-      {
-        "title": "yield 交出一段响应体",
-        "body": "异步生成器可在产生下一段数据前 await。StreamingResponse 消费生成器并发送响应体;生成器必须有明确的正常结束条件。"
-      },
-      {
-        "title": "消息格式属于双方约定",
-        "body": "本课每个 token 事件的 data 是 {text:...},最后发送 done 事件且 data 为 {}。JSON 编码处理引号和换行,空行负责分隔事件。不能直接把任意用户输入拼接成 SSE 行。"
-      },
-      {
-        "title": "响应开始后不能改写 HTTP 状态",
-        "body": "参数验证应在开始流式响应前完成。发送响应头后若处理中断,应通过约定的流内错误事件或连接结束通知客户端;不能再把已经开始的 200 改成 500。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "定义一处统一的事件编码",
-        "body": "把事件名限制为代码中定义的固定值;用 json.dumps 编码数据。返回字符串末尾是两个换行字符,即一个空行。ensure_ascii=False 保留中文可读性。",
-        "code": "import json\n\ndef encode_sse(event: str, data: dict) -> str:\n    payload = json.dumps(data, ensure_ascii=False)\n    return f\"event: {event}\\ndata: {payload}\\n\\n\"",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "产生有限长度 token 流",
-        "body": "每隔约 0.4 秒发送一个 token,完成后发 done。使用 finally 观察连接关闭后的清理路径;真实上游迭代器、文件等资源应在这里或上下文管理器内关闭。不要吞掉取消异常。",
-        "code": "import asyncio\nfrom fastapi import Request\n\nasync def token_events(request: Request, prompt: str):\n    try:\n        for text in [\"收到:\", prompt, \"。处理完成。\"]:\n            await asyncio.sleep(0.4)\n            if await request.is_disconnected():\n                return\n            yield encode_sse(\"token\", {\"text\": text})\n        yield encode_sse(\"done\", {})\n    finally:\n        print(\"stream closed\")",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "在 /stream 返回 StreamingResponse",
-        "body": "GET 查询参数适合 EventSource 演示;不要在 URL 中传密钥或敏感正文。prompt 先经过长度验证,再进入生成器。已有相同路由时替换定义,避免重复注册。",
-        "code": "from fastapi import Query\nfrom fastapi.responses import StreamingResponse\n\n@app.get(\"/stream\")\nasync def stream(\n    request: Request,\n    prompt: str = Query(default=\"hello\", min_length=1, max_length=200),\n):\n    return StreamingResponse(\n        token_events(request, prompt),\n        media_type=\"text/event-stream\",\n        headers={\"Cache-Control\": \"no-cache\"},\n    )",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "从终端逐段观察,再主动取消",
-        "body": "使用 curl.exe 的 -N 禁用它自己的输出缓冲。应先后看到三条 token 与一条 done,每条后有空行。第二次请求在生成中按 Ctrl+C,服务端最终应打印 stream closed;断开通常通过取消路径或断开检查被观察到。",
-        "code": "curl.exe -N -i \"http://127.0.0.1:8000/stream?prompt=hello\"",
-        "lang": "bash"
-      },
-      {
-        "title": "核对线上实际字节格式",
-        "body": "以下是事件体格式展示,其中空行是协议的一部分。data 内的 JSON 字符串若有换行,应由 JSON 编码为转义序列,不能破坏事件边界。",
-        "code": "event: token\ndata: {\"text\": \"hello\"}\n\nevent: done\ndata: {}\n\n",
-        "lang": "http"
-      }
-    ],
-    "checks": [
-      "响应 Content-Type 以 text/event-stream 开头;终端能在流结束前看到 token。",
-      "正常结束只有一个 done,三个 token 的 text 拼接后与 /generate 的 text 相同。",
-      "prompt 为空返回 422;请求中途取消后清理日志出现,服务仍能处理 /health。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "客户端一直等不到事件回调",
-        "cause": "发送了 data 行却漏了事件末尾的空行。",
-        "fix": "每条事件统一通过 encode_sse 编码,并确认末尾为 \\n\\n。"
-      },
-      {
-        "symptom": "输入含换行后事件解析失败",
-        "cause": "手工拼接 JSON 或把原始输入直接放进 data 行。",
-        "fix": "先 json.dumps,再写入 SSE data 字段。"
-      },
-      {
-        "symptom": "断开后仍持续执行,或者没有清理资源",
-        "cause": "无限循环未处理断开、没有 await 让取消生效,或遗漏 finally 清理。",
-        "fix": "在迭代中检查断开,使用可等待的 I/O,并在 finally 中释放资源;保持本课生成器有限长度。"
-      }
-    ],
-    "exercise": {
-      "task": "在三个 token 后、done 前增加一个 stats 事件,返回 token_count:3;保持已有客户端仍能完成。",
-      "hint": "stats 是独立事件类型,不需要把它伪装成 token。",
-      "answer": "在 done 前增加 yield encode_sse(\"stats\", {\"token_count\": 3})。原客户端只监听 token 和 done 时会忽略 stats;新增监听器后可以显示统计值。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:自定义与流式响应",
-        "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
-      },
-      {
-        "label": "MDN:SSE 消息格式",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
-      },
-      {
-        "label": "FastAPI:Request 类与断开检查",
-        "url": "https://fastapi.tiangolo.com/reference/request/"
-      }
-    ]
-  },
-  {
-    "id": "13",
-    "group": "流式与实时通信",
-    "title": "在浏览器中读取流,并在中途停止",
-    "summary": "分别使用 EventSource 与 fetch,处理 UTF-8 分块、事件边界和取消请求。",
-    "objectives": [
-      "用 EventSource 监听命名事件并主动关闭",
-      "用 ReadableStream 解析可能被拆开的 SSE 数据",
-      "通过 AbortController 停止请求并区分取消与错误"
-    ],
-    "concepts": [
-      {
-        "title": "EventSource 简单,但请求形态有限",
-        "body": "原生 EventSource 发起 GET 请求,自动解析 SSE 并具备重连行为;构造参数不能任意设置请求体和自定义请求头。收到本课有限流的 done 后应主动 close,防止结束后重新请求。"
-      },
-      {
-        "title": "fetch 能发送 POST,再读取响应流",
-        "body": "POST 请求不妨碍响应逐段返回。需要 JSON 请求体或自定义头时可用 fetch,但必须自己解析响应体。调用 response.json() 或 response.text() 会等到响应体读完。"
-      },
-      {
-        "title": "网络块不等于事件",
-        "body": "一个中文字符的 UTF-8 字节、一个 JSON 或空行都可能跨越两次 read;一次 read 也可能包含多条事件。先连续解码字节,再缓存文本并按事件边界提取。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "先用 EventSource 接收命名事件",
-        "body": "打开本地 http://127.0.0.1:8000/docs,在开发者工具 Console 运行。URL 与页面同源,先排除 CORS 干扰。控制台应逐条输出文本,done 后主动关闭;本课把网络失败也关闭,避免自动重连重复执行。",
-        "code": "{\n  const source = new EventSource(\"/stream?prompt=hello\");\n  source.addEventListener(\"token\", (event) => {\n    console.log(JSON.parse(event.data).text);\n  });\n  source.addEventListener(\"done\", () => {\n    console.log(\"完成\");\n    source.close();\n  });\n  source.onerror = () => {\n    console.error(\"流连接失败,已停止\");\n    source.close();\n  };\n  window.stopSse = () => source.close();\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "把事件解析与网络读取分开",
-        "body": "先在 Console 定义解析函数。它为本课 LF / CRLF 事件格式处理跨块与多条 data 行,且要求正常收到 done;不实现完整 EventSource 的 id、retry 与自动重连功能。TextDecoder 的 stream:true 会暂存未完整的 UTF-8 字节,最后再刷新。",
-        "code": "async function consumeSse(response, onEvent) {\n  if (!response.ok) throw new Error(`HTTP ${response.status}`);\n  if (!response.headers.get(\"content-type\")?.includes(\"text/event-stream\")) {\n    throw new Error(\"响应不是 SSE\");\n  }\n  if (!response.body) throw new Error(\"没有响应体\");\n  const reader = response.body.getReader();\n  const decoder = new TextDecoder(\"utf-8\");\n  let buffer = \"\";\n  let completed = false;\n  try {\n    while (!completed) {\n      const { value, done } = await reader.read();\n      buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n      let boundary;\n      while ((boundary = /\\r?\\n\\r?\\n/.exec(buffer)) !== null) {\n        const block = buffer.slice(0, boundary.index);\n        buffer = buffer.slice(boundary.index + boundary[0].length);\n        const lines = block.split(/\\r?\\n/);\n        const name = lines.find(line => line.startsWith(\"event:\"))\n          ?.slice(6).trim() || \"message\";\n        const data = lines.filter(line => line.startsWith(\"data:\"))\n          .map(line => line.slice(5).replace(/^ /, \"\")).join(\"\\n\");\n        if (data) onEvent(name, JSON.parse(data));\n        if (name === \"done\") { completed = true; break; }\n      }\n      if (done && !completed) throw new Error(\"连接结束,但没有收到 done\");\n    }\n    await reader.cancel();\n  } catch (error) {\n    await reader.cancel().catch(() => {});\n    throw error;\n  } finally {\n    reader.releaseLock();\n  }\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "用 fetch 读取同一个 GET 接口",
-        "body": "在同一 Console 定义 startStream,然后执行 startStream()。紧接着执行 stopStream() 可中途取消;AbortError 代表用户主动取消,不应显示为服务故障。新的请求使用新的 AbortController。",
-        "code": "async function startStream() {\n  const controller = new AbortController();\n  window.stopStream = () => controller.abort();\n  try {\n    const response = await fetch(\"/stream?prompt=hello\", {\n      signal: controller.signal,\n    });\n    await consumeSse(response, (name, data) => {\n      if (name === \"token\") console.log(data.text);\n      if (name === \"done\") console.log(\"完成\");\n    });\n  } catch (error) {\n    if (error.name === \"AbortError\") console.log(\"已取消\");\n    else console.error(error);\n  }\n}\nstartStream();",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "扩展:需要 POST 请求体时",
-        "body": "先在 main.py 额外加入以下 POST /stream 路由,复用第 11 课 PromptIn 与第 12 课 token_events。然后把上一步 fetch 的 URL 改为 /stream,并在配置对象中加入 method:\"POST\"、headers:{\"Content-Type\":\"application/json\"}、body:JSON.stringify({prompt:\"hello\"});保留 signal。POST /generate 仍返回完整 JSON,不可拿它套用 SSE 解析函数。",
-        "code": "@app.post(\"/stream\")\nasync def stream_post(request: Request, payload: PromptIn):\n    return StreamingResponse(\n        token_events(request, payload.prompt),\n        media_type=\"text/event-stream\",\n        headers={\"Cache-Control\": \"no-cache\"},\n    )",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "核对流为什么没有逐段显示",
-        "body": "先用 curl.exe -N 直连本地 API。若本地逐段到达但页面一次出现,检查是否调用了 response.text/json,或渲染是否缓存了结果。若只有经过中间服务才一次出现,再检查中间服务的响应缓冲;本课只定位差异,具体代理配置留到部署模块。"
-      }
-    ],
-    "checks": [
-      "EventSource 收到三次 token 和一次 done 后关闭,不会周期性重新发起 /stream。",
-      "fetch 版本能正常显示中文;主动 stopStream 后输出“已取消”。",
-      "扩展 POST /stream 返回 SSE;POST /generate 仍返回 application/json。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "偶发 JSON.parse 错误,或中文出现替换字符",
-        "cause": "直接对每次 read 的内容解码和解析,忽略了字符、事件可能跨块。",
-        "fix": "保持同一 TextDecoder 并设置 stream:true,再缓存到完整空行边界后解析。"
-      },
-      {
-        "symptom": "一次成功结束后又出现同样文本",
-        "cause": "有限 SSE 结束后 EventSource 自动重连,客户端没有处理 done。",
-        "fix": "监听 done 调用 close;真实恢复能力需要单独定义,不默认重复执行。"
-      },
-      {
-        "symptom": "点击停止后仍把状态改成失败",
-        "cause": "把所有 fetch 异常都当成网络故障。",
-        "fix": "单独识别 AbortError,并同步把页面状态改为已取消。"
-      }
-    ],
-    "exercise": {
-      "task": "把一条包含“你好”的 SSE 消息拆成逐个字节的 ReadableStream,验证解析函数仍得到完整中文和 done。",
-      "hint": "用 TextEncoder 生成字节,再通过 Response 包装本地 ReadableStream;这只是解析器实验,不是 API 网络测试。",
-      "answer": "构造文本 'event: token\\ndata: {\"text\":\"你好\"}\\n\\nevent: done\\ndata: {}\\n\\n';对 TextEncoder().encode(text) 的每个字节调用 controller.enqueue(new Uint8Array([byte])),最后 close。用 new Response(stream,{headers:{\"Content-Type\":\"text/event-stream\"}}) 传入 consumeSse,回调应只收到完整 token 和 done。"
-    },
-    "refs": [
-      {
-        "label": "MDN:EventSource",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/EventSource"
-      },
-      {
-        "label": "MDN:读取 ReadableStream",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams"
-      },
-      {
-        "label": "MDN:TextDecoder.decode",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode"
-      },
-      {
-        "label": "MDN:AbortController",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/AbortController"
-      }
-    ]
-  },
-  {
-    "id": "14",
-    "group": "流式与实时通信",
-    "title": "建立第一条 WebSocket 双向连接",
-    "summary": "实现 /ws 回显,观察建立、发送、接收和关闭的完整过程。",
-    "objectives": [
-      "使用 accept、receive_text 和 send_json 完成回显",
-      "在浏览器连接打开后发送消息",
-      "捕获断开并释放连接相关资源"
-    ],
-    "concepts": [
-      {
-        "title": "连接先建立,消息再发送",
-        "body": "服务端 accept 后进入收发阶段;浏览器 WebSocket 从 CONNECTING 转为 OPEN 后才能发送。send 只表示数据进入发送流程,不证明对方已经处理。"
-      },
-      {
-        "title": "一条连接,多条消息",
-        "body": "本课客户端发送文本,服务端回复 JSON。普通文本对应 type:echo;文本 ping 对应 type:pong。双方必须约定数据格式,不能把 HTTP 路由的返回值当作 WebSocket 消息发送。"
-      },
-      {
-        "title": "断开是正常生命周期",
-        "body": "用户关页、主动关闭和网络中断都可能结束连接。receive_text 在断开时会抛出 WebSocketDisconnect;在 finally 中清理该连接持有的资源。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "在 main.py 插入 /ws 路由",
-        "body": "接受连接后循环读取消息。回复必须调用 send_json,而不是 return 一个字典。这个本地练习只接收文本消息,不实现用户身份或消息保存。",
-        "code": "from fastapi import WebSocket, WebSocketDisconnect\n\n@app.websocket(\"/ws\")\nasync def websocket_echo(websocket: WebSocket):\n    await websocket.accept()\n    try:\n        while True:\n            text = await websocket.receive_text()\n            if text == \"ping\":\n                await websocket.send_json({\"type\": \"pong\"})\n            else:\n                await websocket.send_json({\"type\": \"echo\", \"text\": text})\n    except WebSocketDisconnect:\n        pass\n    finally:\n        print(\"websocket closed\")",
-        "lang": "python",
-        "filename": "main.py"
-      },
-      {
-        "title": "从浏览器建立连接",
-        "body": "打开本地 http://127.0.0.1:8000/docs,在 Console 运行。代码把连接保留到 window.echoSocket,方便随后发送和关闭。open、message、close 的日志对应三个不同阶段。",
-        "code": "{\n  const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n  window.echoSocket = socket;\n  socket.onopen = () => console.log(\"连接已打开\");\n  socket.onmessage = event => console.log(\"收到\", JSON.parse(event.data));\n  socket.onerror = () => console.error(\"连接发生错误\");\n  socket.onclose = event => console.log(\"已关闭\", event.code, event.reason);\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "在 OPEN 状态发送两种消息",
-        "body": "等“连接已打开”出现后,执行下列代码。在 Network 面板选择 WS 请求并查看消息列表,区分客户端发出的原始文本与服务端返回的 JSON。WebSocket 请求不使用 /docs 的 HTTP Execute 按钮测试。",
-        "code": "if (echoSocket.readyState === WebSocket.OPEN) {\n  echoSocket.send(\"hello\");\n  echoSocket.send(\"ping\");\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "主动关闭并确认生命周期结束",
-        "body": "调用 close 后,浏览器输出关闭事件,服务端输出清理日志。关闭后不能复用旧 socket 继续发送;需要新建 WebSocket。重新连接并再发送一次 hello,确认服务仍可用。",
-        "code": "echoSocket.close(1000, \"lesson finished\");",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      }
-    ],
-    "checks": [
-      "浏览器先收到 open,发送 hello 后收到 {type:'echo',text:'hello'}。",
-      "发送 ping 后收到 {type:'pong'};同一连接可重复收发。",
-      "主动关闭后收到 close,服务端清理日志出现,重新连接仍正常。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "调用 send 报连接尚未建立",
-        "cause": "new WebSocket 返回时握手仍在进行。",
-        "fix": "在 open 回调中发送,或先检查 readyState === WebSocket.OPEN。"
-      },
-      {
-        "symptom": "浏览器无任何回复,路由却 return 了字典",
-        "cause": "WebSocket 消息不会像 HTTP 响应一样自动序列化返回。",
-        "fix": "在连接内 await websocket.send_json(...)。"
-      },
-      {
-        "symptom": "把 WebSocket 地址粘到普通地址栏后得到 404",
-        "cause": "使用 HTTP GET 测试了 WebSocket 端点,没有进行 WebSocket 握手。",
-        "fix": "通过浏览器 WebSocket API 或支持 WS 的客户端测试,使用 ws:// 地址。"
-      }
-    ],
-    "exercise": {
-      "task": "增加一个文本命令 upper:hello,让服务端回复 {type:'upper',text:'HELLO'},其他消息保持回显。",
-      "hint": "把 startswith('upper:') 分支放在默认 echo 分支之前。",
-      "answer": "在 echo 的 else 之前加入 elif text.startswith(\"upper:\"): await websocket.send_json({\"type\":\"upper\",\"text\":text.removeprefix(\"upper:\").upper()})。浏览器收到的 JSON type 应为 upper。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:WebSocket 收发与断开",
-        "url": "https://fastapi.tiangolo.com/advanced/websockets/"
-      },
-      {
-        "label": "MDN:编写 WebSocket 客户端",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
-      }
-    ]
-  },
-  {
-    "id": "15",
-    "group": "流式与实时通信",
-    "title": "连接断开后:重连、心跳与取消",
-    "summary": "给 WebSocket 加上有限重试和定时器清理,验证停止后不会自行恢复。",
-    "objectives": [
-      "在异常关闭后用指数退避重新连接",
-      "通过应用层 ping / pong 检测连接是否仍可用",
-      "清理重连和心跳定时器,不自动重发未确认消息"
-    ],
-    "concepts": [
-      {
-        "title": "重连创建一条新连接",
-        "body": "新连接不会自动恢复旧连接内的执行状态,也不保证断开期间消息送达。本课不缓存、不重发业务消息;需要可靠恢复时,必须另行设计消息编号、确认和服务端去重。"
-      },
-      {
-        "title": "退避限制重试频率",
-        "body": "连续失败时逐渐延长等待,并加少量随机抖动,避免同时快速请求。设置最大次数;用户主动停止后取消待执行重连。"
-      },
-      {
-        "title": "心跳与页面状态",
-        "body": "这里的 ping 是普通文本消息,pong 是业务 JSON,属于应用层心跳。浏览器 WebSocket API 不提供主动发送协议级 Ping 帧的方法。计时器会受后台页面节流影响,超时阈值需要按场景调整。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "先定义连接状态与定时器清理",
-        "body": "在本地 /docs 的 Console 按步骤运行。先关闭上一课 echoSocket,避免同时观察两条连接。下面状态只属于本次练习;刷新页面可重新开始。",
-        "code": "let wsSocket = null;\nlet retryTimer = null;\nlet heartbeatTimer = null;\nlet pongTimer = null;\nlet wsStopped = true;\nlet retryCount = 0;\n\nfunction clearHeartbeat() {\n  clearInterval(heartbeatTimer);\n  clearTimeout(pongTimer);\n  heartbeatTimer = null;\n  pongTimer = null;\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "加入应用层心跳",
-        "body": "每 5 秒发送一次 ping,3 秒内没有 pong 就关闭连接,让同一条关闭处理路径决定是否重连。收到 pong 时取消对应超时。这里只允许一个等待中的心跳。",
-        "code": "function startHeartbeat(socket) {\n  clearHeartbeat();\n  heartbeatTimer = setInterval(() => {\n    if (socket.readyState !== WebSocket.OPEN) return;\n    socket.send(\"ping\");\n    pongTimer = setTimeout(() => {\n      socket.close(4000, \"heartbeat timeout\");\n    }, 3000);\n  }, 5000);\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "把重连集中在 close 事件中",
-        "body": "首次失败约 0.5 秒后重连,随后约 1、2、4、8 秒,另加少量随机抖动;最多连续重试 5 次。error 只记录,避免 error 和 close 各重试一次。收到服务端有效消息后清零连续失败计数。",
-        "code": "function connectWs() {\n  if (wsStopped) return;\n  const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n  wsSocket = socket;\n  socket.onopen = () => {\n    if (wsStopped || wsSocket !== socket) { socket.close(); return; }\n    console.log(\"已连接\");\n    startHeartbeat(socket);\n  };\n  socket.onmessage = event => {\n    if (wsSocket !== socket) return;\n    const message = JSON.parse(event.data);\n    retryCount = 0;\n    if (message.type === \"pong\") {\n      clearTimeout(pongTimer);\n      pongTimer = null;\n    } else {\n      console.log(\"收到\", message);\n    }\n  };\n  socket.onerror = () => console.log(\"连接错误,等待 close\");\n  socket.onclose = () => {\n    if (wsSocket !== socket) return;\n    clearHeartbeat();\n    if (wsStopped) return;\n    if (retryCount >= 5) {\n      wsStopped = true;\n      console.log(\"连续重试已达上限,请手动开始\");\n      return;\n    }\n    const delay = Math.min(8000, 500 * 2 ** retryCount) + Math.random() * 200;\n    retryCount += 1;\n    console.log(`第 ${retryCount} 次重试,等待 ${Math.round(delay)}ms`);\n    retryTimer = setTimeout(connectWs, delay);\n  };\n}",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "实现开始、发送与停止",
-        "body": "开始函数防止重复创建连接;发送函数在未连接时明确拒绝;停止函数先记录主动停止,再清理所有计时器和连接。stopWs 同时可供页面关闭时调用。",
-        "code": "function startWs() {\n  if (!wsStopped) return;\n  wsStopped = false;\n  retryCount = 0;\n  connectWs();\n}\nfunction sendWs(text) {\n  if (wsSocket?.readyState !== WebSocket.OPEN) {\n    console.log(\"尚未连接,本条消息未发送\");\n    return;\n  }\n  wsSocket.send(text);\n}\nfunction stopWs() {\n  wsStopped = true;\n  clearTimeout(retryTimer);\n  clearHeartbeat();\n  wsSocket?.close(1000, \"user stopped\");\n  wsSocket = null;\n  console.log(\"已停止\");\n}\nwindow.addEventListener(\"pagehide\", stopWs);\nstartWs();",
-        "lang": "javascript",
-        "filename": "浏览器 Console"
-      },
-      {
-        "title": "执行三组失败实验",
-        "body": "正常连接后用 sendWs('hello') 验证回显。接着停止本地服务,观察逐渐延长的重试间隔,重启服务后确认新连接可收发。最后在下一次重试前调用 stopWs(),等待超过 9 秒,确认没有新建 WS 请求。断线期间尝试发送的消息不能在重连后自动出现。"
-      }
-    ],
-    "checks": [
-      "服务停止时重连间隔逐步增加,连续失败达到 5 次重试后停止。",
-      "服务恢复后能建立新连接并再次收到 echo,心跳 ping 对应 pong。",
-      "stopWs 后超过 9 秒没有新连接;未连接时调用 sendWs 不会排队重发。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "每次断线都会产生两条新连接",
-        "cause": "在 error 和 close 中分别启动重试,或开始按钮允许重复创建。",
-        "fix": "只在 close 调度重试;开始前检查当前是否已经运行。"
-      },
-      {
-        "symptom": "点停止后几秒钟又自动连上",
-        "cause": "只关闭 socket,没有取消已经安排的 setTimeout。",
-        "fix": "先设置主动停止标记,再清理重试、心跳和 pong 超时定时器。"
-      },
-      {
-        "symptom": "重连后重复执行了一条操作",
-        "cause": "客户端自动重发消息,但无法确定服务端在断开前是否已经执行。",
-        "fix": "本课不自动重发;需要重试业务操作时另行加入确认、操作编号与幂等处理。"
-      }
-    ],
-    "exercise": {
-      "task": "把连续重试上限改为 3 次,并验证用户手动 startWs() 后可以开始新一轮连接。",
-      "hint": "更改重试边界,保留达到上限时 wsStopped=true,以及开始时 retryCount=0。",
-      "answer": "把 if (retryCount >= 5) 改为 >= 3。连续失败后控制台打印到达上限;随后调用 startWs() 重置次数并重新连接,若服务已恢复即可继续回显。"
-    },
-    "refs": [
-      {
-        "label": "MDN:WebSocket 客户端生命周期",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
-      },
-      {
-        "label": "MDN:WebSocket.close",
-        "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close"
-      },
-      {
-        "label": "RFC 6455:异常关闭后的恢复建议",
-        "url": "https://www.rfc-editor.org/rfc/rfc6455#section-7.2.3"
-      }
-    ]
-  },
-  {
-    "id": "16",
-    "group": "联调与验收",
-    "title": "把能运行的接口变成可重复验收的服务",
-    "summary": "用 pytest 检查 CRUD、错误、认证、SSE 和 WebSocket,再完成一次真实浏览器联调。",
-    "objectives": [
-      "使用 TestClient 在测试中运行 FastAPI 应用",
-      "为正常路径和 401 / 404 / 422 写出明确断言",
-      "区分接口逻辑验收与真实网络流式验证"
-    ],
-    "concepts": [
-      {
-        "title": "断言检查可观察结果",
-        "body": "测试应检查状态码、响应字段与操作后结果,而不是只确认请求没报异常。每个测试创建自己的数据,通过返回 id 继续操作,避免假设某条笔记固定为 id=1。"
-      },
-      {
-        "title": "TestClient 不等同于浏览器",
-        "body": "TestClient 基于 HTTPX,可在进程内调用应用,不必先启动 Uvicorn。用 with 管理客户端才会执行 lifespan;它不验证浏览器 CORS,也不能证明真实网络按预期逐段到达。"
-      },
-      {
-        "title": "错误与正常结束分别验收",
-        "body": "422 验证输入约束;401 验证 /private 的 API Key;404 验证资源不存在。SSE 既检查事件数据,也检查 done;WebSocket 检查来回消息并关闭会话。"
-      }
-    ],
-    "steps": [
-      {
-        "title": "建立测试客户端",
-        "body": "在 api-lab 新建 tests/test_acceptance.py;以下第 1–4 步代码依次放入同一文件。先运行 python -m pip install pytest httpx。monkeypatch 只在测试期间设置演示 Key,测试结束自动恢复;导入入口为根目录 main.py。",
-        "code": "import json\nfrom uuid import uuid4\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom main import app\n\n@pytest.fixture\ndef client(monkeypatch):\n    monkeypatch.setenv(\"DEMO_API_KEY\", \"classroom-key\")\n    with TestClient(app) as test_client:\n        yield test_client\n\ndef test_health(client):\n    response = client.get(\"/health\")\n    assert response.status_code == 200",
-        "lang": "python",
-        "filename": "tests/test_acceptance.py"
-      },
-      {
-        "title": "验证创建、查询、更新与删除",
-        "body": "从 POST 返回值提取 id,再验证默认值、列表、PUT 完整替换和 DELETE 后 404。PUT 必须包含 title、content、pinned 三个可写字段。DELETE 204 响应体应为空,不能再对它调用 json()。",
-        "code": "def test_note_lifecycle(client):\n    title = f\"acceptance-{uuid4()}\"\n    created = client.post(\"/notes\", json={\"title\": title})\n    assert created.status_code == 201\n    note = created.json()\n    note_id = note[\"id\"]\n    assert note[\"content\"] == \"\"\n    assert note[\"pinned\"] is False\n    assert client.get(f\"/notes/{note_id}\").json() == note\n\n    listed = client.get(\"/notes\", params={\"q\": title, \"limit\": 100})\n    assert listed.status_code == 200\n    assert any(item[\"id\"] == note_id for item in listed.json())\n\n    replacement = {\"title\": \"更新后\", \"content\": \"测试正文\", \"pinned\": True}\n    updated = client.put(f\"/notes/{note_id}\", json=replacement)\n    assert updated.status_code == 200\n    assert updated.json() == {\"id\": note_id, **replacement}\n    assert client.get(f\"/notes/{note_id}\").json() == updated.json()\n\n    deleted = client.delete(f\"/notes/{note_id}\")\n    assert deleted.status_code == 204\n    assert deleted.content == b\"\"\n    missing = client.get(f\"/notes/{note_id}\")\n    assert missing.status_code == 404\n    assert missing.json()[\"error\"][\"code\"] == \"http_error\"",
-        "lang": "python",
-        "filename": "tests/test_acceptance.py"
-      },
-      {
-        "title": "验证输入错误与受保护接口",
-        "body": "空标题应被输入校验拒绝。只有 /private 在本地样例中需要 X-API-Key;不要把笔记的公开练习路由与认证演示混在一起。错误体沿用前面课程的统一结构 error.code / message / details。",
-        "code": "def test_validation(client):\n    invalid = client.post(\"/notes\", json={\"title\": \"\"})\n    assert invalid.status_code == 422\n    assert invalid.json()[\"error\"][\"code\"] == \"validation_error\"\n    assert client.get(\"/slow\", params={\"delay\": -1}).status_code == 422\n\n\ndef test_api_key(client):\n    assert client.get(\"/private\").status_code == 401\n    wrong = client.get(\"/private\", headers={\"X-API-Key\": \"wrong-key\"})\n    assert wrong.status_code == 401\n    assert wrong.json()[\"error\"][\"code\"] == \"http_error\"\n    valid = client.get(\"/private\", headers={\"X-API-Key\": \"classroom-key\"})\n    assert valid.status_code == 200",
-        "lang": "python",
-        "filename": "tests/test_acceptance.py"
-      },
-      {
-        "title": "验证 SSE 内容和 WebSocket 来回消息",
-        "body": "TestClient 读取有限 SSE 的完整响应后检查协议内容;该测试不用于断言首段到达时间。WebSocket 使用上下文管理器,离开时关闭连接。 如果完成了第 12 课的 stats 扩展,测试允许保留该事件,只对 token/done 的主线顺序和内容断言;done 仍必须是最后一个事件。",
-        "code": "def test_stream_matches_complete_result(client):\n    complete = client.post(\"/generate\", json={\"prompt\": \"hello\"})\n    assert complete.status_code == 200\n    response = client.get(\"/stream\", params={\"prompt\": \"hello\"})\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"].startswith(\"text/event-stream\")\n    events = []\n    for block in response.text.replace(\"\\r\\n\", \"\\n\").strip().split(\"\\n\\n\"):\n        fields = {}\n        for line in block.split(\"\\n\"):\n            name, value = line.split(\":\", 1)\n            fields[name] = value.lstrip(\" \")\n        events.append((fields[\"event\"], json.loads(fields[\"data\"])))\n    assert [name for name, _ in events if name != \"stats\"] == [\"token\", \"token\", \"token\", \"done\"]\n    text = \"\".join(data[\"text\"] for name, data in events if name == \"token\")\n    assert text == complete.json()[\"text\"]\n    assert events[-1] == (\"done\", {})\n\n\ndef test_websocket(client):\n    with client.websocket_connect(\"/ws\") as socket:\n        socket.send_text(\"hello\")\n        assert socket.receive_json() == {\"type\": \"echo\", \"text\": \"hello\"}\n        socket.send_text(\"ping\")\n        assert socket.receive_json() == {\"type\": \"pong\"}",
-        "lang": "python",
-        "filename": "tests/test_acceptance.py"
-      },
-      {
-        "title": "运行自动验收,再测真实网络",
-        "body": "在 api-lab 执行 python -m pytest -q tests/test_acceptance.py。通过后另开终端运行 python -m uvicorn main:app --reload,再运行下面脚本。SSE 的 data 行应分批出现,时间通常约为 0.4、0.8、1.2 秒,而非都等到最后。最后在浏览器完成 CORS、主动停止流、WebSocket 断开重连三个场景。",
-        "code": "from time import perf_counter\nimport httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5.0) as client:\n    started = perf_counter()\n    with client.stream(\"GET\", \"/stream\", params={\"prompt\": \"hello\"}) as response:\n        response.raise_for_status()\n        for line in response.iter_lines():\n            if line.startswith(\"data:\"):\n                print(f\"{perf_counter() - started:.2f}s {line}\", flush=True)",
-        "lang": "python",
-        "filename": "verify_stream_network.py"
-      }
-    ],
-    "checks": [
-      "自动测试覆盖健康检查、完整 CRUD、422 校验、401 认证、删除后 404、SSE 与 WebSocket,运行结果全部通过。",
-      "真实 HTTPX / curl 客户端在响应结束前接收到 token,浏览器停止读取后服务端执行清理。",
-      "允许来源的浏览器能够调用 API;未允许来源不能读取响应;WebSocket 主动停止后不再重连。"
-    ],
-    "pitfalls": [
-      {
-        "symptom": "单个测试能过,整套测试偶尔失败",
-        "cause": "多个测试假定相同 id 或依赖前一个测试创建的数据。",
-        "fix": "每个测试自行创建记录,用返回 id 操作,并在生命周期测试中删除。"
-      },
-      {
-        "symptom": "测试里 app.state.http 不存在",
-        "cause": "没有通过 with TestClient(app) 启动 lifespan,或代码未正确注册 lifespan。",
-        "fix": "在 fixture 的上下文中创建客户端,并核对 FastAPI(lifespan=lifespan)。"
-      },
-      {
-        "symptom": "SSE 测试通过,网页仍一次显示全部内容",
-        "cause": "进程内测试只检查完整内容,没有经过浏览器、真实网络和中间服务。",
-        "fix": "用真实 Uvicorn 配合 httpx.stream / curl -N 观察时间,再检查浏览器读取与渲染。"
-      }
-    ],
-    "exercise": {
-      "task": "增加一个测试:创建笔记后,PUT 只发送 title 应返回 422,随后 GET 仍返回原来的完整内容。",
-      "hint": "输入不合法时不能发生部分更新;先保存创建响应,再比较读取结果。",
-      "answer": "创建笔记并保存 note;使用 client.put(f'/notes/{note[\"id\"]}', json={\"title\":\"修改\"}),断言 422;再次 GET 并断言等于 note,最后 DELETE 清理。这个测试同时检查完整替换约定和校验失败不写入的行为。"
-    },
-    "refs": [
-      {
-        "label": "FastAPI:TestClient 与 pytest",
-        "url": "https://fastapi.tiangolo.com/tutorial/testing/"
-      },
-      {
-        "label": "FastAPI:测试 lifespan",
-        "url": "https://fastapi.tiangolo.com/advanced/testing-events/"
-      },
-      {
-        "label": "FastAPI:测试 WebSocket",
-        "url": "https://fastapi.tiangolo.com/advanced/testing-websockets/"
-      },
-      {
-        "label": "pytest:fixtures",
-        "url": "https://docs.pytest.org/en/stable/how-to/fixtures.html"
-      },
-      {
-        "label": "pytest:临时环境变量",
-        "url": "https://docs.pytest.org/en/stable/how-to/monkeypatch.html"
-      },
-      {
-        "label": "HTTPX:流式响应",
-        "url": "https://www.python-httpx.org/quickstart/"
-      }
-    ]
-  }
-]
+[
+  {
+    "id": "01",
+    "group": "起步与 HTTP",
+    "title": "启动第一个 FastAPI 服务",
+    "summary": "从空文件夹开始,在浏览器看到 {\"status\":\"ok\"},修改文件后还能看到结果随之变化。",
+    "objectives": [
+      "亲手创建文件夹、安装所需包并写出 main.py",
+      "启动服务后用浏览器和文档页面拿到同一结果",
+      "改一行内容并验证更新,最后能正常停止和重新启动"
+    ],
+    "concepts": [
+      {
+        "title": "独立环境解决“用哪个 Python”",
+        "body": "回看第 3、4 步,.venv 创建环境,激活选择当前终端使用的环境,sys.executable 给出证据;python -m pip 把包安装到同一个 Python 下。"
+      },
+      {
+        "title": "路由把地址与函数接起来",
+        "body": "第 9、10 步通过两种入口都拿到 ok:GET /health 先匹配 @app.get,再运行 health,字典被转换成 JSON。状态码 200 与 JSON 内容各自承担不同信息。"
+      },
+      {
+        "title": "服务进程需要保持运行",
+        "body": "第 8 步的终端持续等待,第 11 步 Ctrl+C 后连接失败。Uvicorn 持续接收请求;main:app 指向代码对象,127.0.0.1:8000 指向本机接收位置。"
+      },
+      {
+        "title": "自动重载是开发时的便利",
+        "body": "第 11 步保存文件就看到 ready,这是 --reload 的效果。它会重新启动应用,后面用内存保存的商品也会随重载清空,不能把它当成持久保存。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "打开终端,确认 Python 可以使用",
+        "lang": "text",
+        "code": "Windows PowerShell:py -3 --version\nmacOS / Linux 终端:python3 --version",
+        "body": "Windows 打开开始菜单中的 PowerShell;macOS 打开“终端”;Linux 打开终端应用。下方两行是系统对应表,只输入与你的系统对应的冒号后命令,不要复制系统名称,也不要两行都执行。",
+        "expected": "终端显示 Python 3.11.x 或更高版本,然后重新出现输入提示符。如果显示“找不到命令”,先检查 Python 安装。",
+        "explanation": "终端是输入命令并查看程序输出的窗口。--version 只询问 Python 的版本,还没有启动我们的商品服务;Windows 常用 py -3 选择 Python 3,另外两个系统常用 python3。"
+      },
+      {
+        "title": "创建商品服务的工作文件夹",
+        "lang": "bash",
+        "code": "mkdir api-lab\ncd api-lab",
+        "body": "在编辑器打开准备放练习的父文件夹,并在这里打开终端;也可以先用 cd 切换到该位置。依次执行下面两行。若 api-lab 已存在,先确认它是本课练习目录,再只执行 cd api-lab,不要覆盖已有项目。",
+        "expected": "文件管理器中出现 api-lab;终端当前路径末尾为 api-lab。此时目录里还没有 main.py,这正是预期起点。",
+        "explanation": "mkdir 创建文件夹,cd 把终端的工作位置切换进去。后面不写绝对路径的命令都会以这里为起点,因此文件放错目录会导致启动时找不到 main.py。"
+      },
+      {
+        "title": "为这个项目创建独立环境",
+        "lang": "bash",
+        "code": "# Windows PowerShell:只执行这两行\npy -3 -m venv .venv\n.\\.venv\\Scripts\\Activate.ps1\n\n# macOS / Linux:只执行这两行\npython3 -m venv .venv\nsource .venv/bin/activate",
+        "body": "在刚才的终端中,只执行下方对应系统的两行。第一行可能需要等待片刻。Windows 如果提示脚本被禁止,不修改全局策略,改用 .\\.venv\\Scripts\\python.exe 代替本课后续命令开头的 python。",
+        "expected": "api-lab 中出现 .venv 文件夹;激活后提示符通常显示 (.venv)。提示符未显示时先做下一步路径检查,不仅凭外观判断。",
+        "explanation": "刚建立的 .venv 是这个项目专用的 Python 环境,也叫虚拟环境。它让之后安装的包放在本项目下。激活只把当前终端的 python 命令指向该环境,关掉终端后需要重新进入目录并激活。"
+      },
+      {
+        "title": "确认后续命令用的是项目里的 Python",
+        "lang": "bash",
+        "code": "python -c \"import sys; print(sys.executable)\"",
+        "body": "执行下面的检查命令。若第 3 步采用了 Windows 不激活方案,将开头的 python 换成 .\\.venv\\Scripts\\python.exe。编辑器也选择此路径作为 Python 解释器。",
+        "expected": "输出路径包含 api-lab\\.venv 或 api-lab/.venv。如果指向系统安装目录,回到第 3 步激活后重试。",
+        "explanation": "sys.executable 显示这次实际运行的 Python 路径。我们现在确认的是“将由谁安装包、由谁运行代码”,从而避免包装在一个环境、服务却在另一个环境启动。"
+      },
+      {
+        "title": "写下本项目要安装的三个包",
+        "body": "在编辑器中打开 api-lab,创建 requirements.txt,把下方三行原样保存为文件内容;这三行不是终端命令。确认文件名没有变成 requirements.txt.txt。下载完整示例时可沿用包内已锁定的 requirements.txt。",
+        "filename": "requirements.txt",
+        "lang": "bash",
+        "code": "fastapi>=0.115,<1\nuvicorn[standard]>=0.30,<1\npydantic>=2,<3",
+        "expected": "api-lab/requirements.txt 中有三行包名与版本范围,和 .venv 位于同一层。到这一步没有安装输出是正常的。",
+        "explanation": "FastAPI 帮我们处理网页发来的请求;Uvicorn 负责持续接收请求并把它交给程序;Pydantic 后面负责检查商品字段。>= 和 < 指定可用版本范围,本课程使用 Pydantic 2 的写法。"
+      },
+      {
+        "title": "安装包并记录实际版本",
+        "lang": "bash",
+        "code": "python -m pip install -r requirements.txt\npython -m pip freeze > requirements-lock.txt",
+        "body": "回到已通过路径检查的终端,逐行运行下面命令。等待第一条没有错误地结束,再运行第二条;如果第一条下载失败,先按报错检查网络,再重新执行安装。",
+        "expected": "安装过程结束且没有 ERROR;已经安装时会看到 Requirement already satisfied。随后出现 requirements-lock.txt,里面列出实际安装的精确版本。",
+        "explanation": "python -m pip 让刚确认过的 Python 运行它的包安装工具;-r 指示从文件读取包清单。freeze 把实际版本写下来,今后可用 python -m pip install -r requirements-lock.txt 重建相同版本的环境。"
+      },
+      {
+        "title": "写下第一个能返回结果的接口",
+        "body": "在 api-lab 根目录创建 main.py,粘贴下面完整代码并保存。注意文件名和缩进:return 前有四个空格;此时只创建一次 app,不把这段代码重复粘贴两遍。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from fastapi import FastAPI\n\napp = FastAPI(title=\"API Lab\", version=\"0.1.0\")\n\n@app.get(\"/health\", tags=[\"system\"])\ndef health():\n    return {\"status\": \"ok\"}",
+        "expected": "编辑器中 main.py 与 requirements.txt 同级。保存文件本身不会让浏览器出现结果,下一步才真正运行服务。",
+        "explanation": "app = FastAPI(...) 建立应用对象;紧挨 health 函数上方的 @app.get(\"/health\") 把 GET /health 与这个函数关联,这种关联叫路由。请求到来时函数返回 Python 字典,框架再把字典转换成网页能收到的 JSON 文本。"
+      },
+      {
+        "title": "运行 main.py 中的服务",
+        "body": "在 main.py 所在的 api-lab 终端运行下面命令,保留这个窗口。若 8000 被占用,按 Ctrl+C 后在命令末尾加 --port 8001 重试,并把后续地址中的 8000 都换成 8001。",
+        "lang": "bash",
+        "code": "python -m uvicorn main:app --reload",
+        "expected": "终端出现 Uvicorn running on http://127.0.0.1:8000 和 Application startup complete,随后一直等待。若出现 Could not import module,先检查当前目录和 main.py 的拼写。",
+        "explanation": "main:app 指从 main.py 取出 app;127.0.0.1 指本机,8000 是这次接收请求的端口。终端一直等待的是服务进程,它不会像普通脚本那样执行一次就结束。--reload 让它在保存代码后重新加载,适合本机开发。"
+      },
+      {
+        "title": "在浏览器里收到第一条结果",
+        "lang": "text",
+        "code": "http://127.0.0.1:8000/health\n\n预期页面内容:{\"status\":\"ok\"}",
+        "body": "在浏览器地址栏输入下方第一行地址并回车。下方“预期页面内容”仅用于对照,不是要输入的命令。保留终端,刷新页面一次,查看终端是否多出访问记录。",
+        "expected": "浏览器显示 {\"status\":\"ok\"}(空格与排版可能不同),终端出现 GET /health 200。直接访问没有 /health 的根地址会得到 404,并不表示服务没启动。",
+        "explanation": "浏览器访问地址发送了一次 GET 请求;/health 选中了我们刚写的函数。200 是服务对此次请求给出的成功状态;字典转换后的 {\"status\":\"ok\"} 是响应内容。404 则表示程序没有为该地址注册处理函数。"
+      },
+      {
+        "title": "用文档页面发送同一请求",
+        "lang": "text",
+        "code": "http://127.0.0.1:8000/docs\n\nGET /health → Try it out → Execute",
+        "body": "在浏览器打开下面的 /docs 地址,展开 GET /health,依次点击 Try it out 和 Execute。向下找到 Server response,比较 Code 与 Response body;这里不需要填写任何商品字段。",
+        "expected": "Code 为 200,Response body 仍为 {\"status\":\"ok\"}。每点击一次 Execute,终端都会出现新的请求记录。",
+        "explanation": "/docs 是 FastAPI 根据路由生成的交互文档,Execute 会真实发送请求。浏览器地址栏和这个按钮是两种客户端入口,最终调用的是同一个 health 函数。"
+      },
+      {
+        "title": "修改一处返回值,看到服务更新",
+        "lang": "text",
+        "code": "修改前:return {\"status\": \"ok\"}\n观察时:return {\"status\": \"ready\"}\n完成后:return {\"status\": \"ok\"}",
+        "body": "仅把 main.py 中 return 字典里的 ok 改为 ready 并保存,等终端完成重载后刷新 /health。看到变化后改回 ok 再刷新。最后按 Ctrl+C 停止服务,再执行上一条启动命令,为下一课保留运行状态。",
+        "expected": "页面先显示 ready,恢复后显示 ok。Ctrl+C 后刷新可能连接失败;重新启动后又能收到 ok。",
+        "explanation": "我们刚看到 --reload 在文件保存后重启应用;因此不需要每次手工重启。服务停止后浏览器无法获得响应,说明结果来自正在运行的程序,而不是仅仅因为磁盘上有 main.py。"
+      }
+    ],
+    "checks": [
+      "终端的 sys.executable 路径包含 api-lab/.venv。",
+      "GET /health 返回 200 和 {\"status\":\"ok\"};访问未定义的 / 返回 404,这不表示服务未启动。",
+      "/docs 中出现 GET /health,点击 Execute 后能看到真实响应。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "ModuleNotFoundError: No module named 'fastapi'",
+        "cause": "安装依赖和启动服务使用了不同解释器。",
+        "fix": "激活 .venv,检查 sys.executable,再执行 python -m pip install -r requirements.txt。"
+      },
+      {
+        "symptom": "Could not import module 'main'",
+        "cause": "当前目录没有 main.py,或者文件中存在语法错误。",
+        "fix": "切换到 api-lab 根目录,确认文件名为 main.py,阅读终端中最早出现的 Python 错误。"
+      },
+      {
+        "symptom": "PowerShell 阻止 Activate.ps1 运行",
+        "cause": "当前执行策略限制脚本运行。",
+        "fix": "无需修改全局策略,可直接使用 .\\.venv\\Scripts\\python.exe -m pip install -r requirements.txt,以及 .\\.venv\\Scripts\\python.exe -m uvicorn main:app --reload。"
+      }
+    ],
+    "exercise": {
+      "task": "添加 GET /version,返回 {\"service\":\"api-lab\",\"version\":\"0.1.0\"},并在 /docs 中执行一次。",
+      "hint": "保留已有 app,在另一个函数上方注册新的 GET 路由。",
+      "answer": "在 main.py 末尾添加:\n@app.get('/version')\ndef version():\n    return {'service': 'api-lab', 'version': '0.1.0'}\n\n保存后访问 /version,应返回 200;刷新 /docs 后应同时看到 /health 与 /version。"
+    },
+    "refs": [
+      {
+        "label": "Python:venv",
+        "url": "https://docs.python.org/3/library/venv.html"
+      },
+      {
+        "label": "FastAPI:First Steps",
+        "url": "https://fastapi.tiangolo.com/tutorial/first-steps/"
+      },
+      {
+        "label": "FastAPI:手动运行服务器",
+        "url": "https://fastapi.tiangolo.com/deployment/manually/"
+      }
+    ],
+    "intro": {
+      "scenario": "我们要逐步做一个只管理陶瓷杯、帆布袋的小商品目录。这一课先不录入商品,只做一个能回答“服务现在能回应吗”的地址;浏览器能看到 ok,后面的商品操作就有了入口。后续只涉及商品名称、描述、整数价格分、上架状态与简单分类,不加入订单、支付或库存扣减。",
+      "startingPoint": [
+        "会用编辑器创建并保存文本文件,会读简单的 Python 函数和字典;不需要先学过网站开发。",
+        "电脑已安装 Python 3.11 或更新版本,并准备好一个代码编辑器。若第 1 步提示找不到命令,先完成 Python 安装再继续。",
+        "选一个你有写入权限的练习位置,例如“文档”。从零跟做时创建新的 api-lab 文件夹;若使用下载的完整示例,请另建一个练习文件夹,避免把完整项目与本课逐步代码混写。"
+      ],
+      "outcome": "从空文件夹开始,在浏览器看到 {\"status\":\"ok\"},修改文件后还能看到结果随之变化。"
+    }
+  },
+  {
+    "id": "02",
+    "group": "起步与 HTTP",
+    "title": "读懂一次 HTTP 请求与响应",
+    "summary": "发送包含陶瓷杯信息的请求,收到原样返回的数据,并能指出状态码和商品数据分别在哪里。",
+    "demo": "http",
+    "objectives": [
+      "对照一次 /health 访问找到请求地址与返回内容",
+      "把商品信息保存成 JSON 文件并从终端发送",
+      "修改商品名称再次发送,确认服务收到了新值"
+    ],
+    "concepts": [
+      {
+        "title": "一次往返要分方向阅读",
+        "body": "第 3、4 步修改文件后,received 随之变化,证明请求体先传到服务器,再作为响应的一部分返回。方法、路径和头部告诉服务如何处理,JSON 则承载商品字段。"
+      },
+      {
+        "title": "格式说明与字段内容分开",
+        "body": "第 3 步同时发送 Content-Type: application/json 和文件内容:头部声明格式,文件提供实际数据。Accept 只是客户端偏好,不会自动改变接口实现。"
+      },
+      {
+        "title": "200 不等于商品已入库",
+        "body": "本课第 4 步得到 200 但没有 id,函数也没有保存代码。状态码说明本接口操作是否成功,是否真正创建商品还要看接口约定和后续读取结果。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "观察最小 GET 请求",
+        "body": "在浏览器打开 /docs,展开 GET /health 并点击 Try it out → Execute。把下方协议展示与 Server response 对照;该代码块是请求和响应的阅读示意,不要粘贴到终端。",
+        "lang": "http",
+        "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}",
+        "expected": "/docs 的 Code 是 200,Response body 是 {\"status\":\"ok\"};展示中的 GET /health 对应这次访问,空行后的 JSON 对应返回内容。",
+        "explanation": "GET 是请求方法,/health 是路径,Host 指访问的主机和端口。头部与内容之间用空行分开;这个 GET 没有携带请求体,仍然可以收到响应体。"
+      },
+      {
+        "title": "添加临时的 POST 回显接口",
+        "body": "把下面完整代码追加到 api-lab/main.py 末尾,保存并等待重载。本课只写这一份 POST /products,稍后第 03 课会将它替换。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from fastapi import Request\n\n@app.post(\"/products\")\nasync def inspect_product(request: Request):\n    data = await request.json()\n    return {\"received\": data}",
+        "expected": "刷新 /docs 后出现 POST /products;这时 GET /health 仍可返回 ok。若导入失败,检查 Request 导入和函数缩进。",
+        "explanation": "Request 让函数拿到原始请求;await request.json() 读取并解码发送来的 JSON,received 包住这份数据再返回。async / await 先按示例使用,第 09 课会用计时实验解释等待;这个函数没有任何保存动作。"
+      },
+      {
+        "title": "准备 JSON 文件并发送 POST",
+        "body": "在 api-lab/product.json 保存下方内容,使用 UTF-8。macOS / Linux 在第二个终端执行 curl -i -X POST http://127.0.0.1:8000/products -H \"Content-Type: application/json\" --data-binary @product.json;Windows PowerShell 使用 curl.exe -i -X POST http://127.0.0.1:8000/products -H \"Content-Type: application/json\" --data-binary \"@product.json\"。文件内容不是终端命令。",
+        "filename": "product.json",
+        "lang": "json",
+        "code": "{\n  \"name\": \"ceramic mug\",\n  \"description\": \"learn HTTP\",\n  \"is_active\": false, \"price_cents\": 0\n}",
+        "expected": "终端显示 HTTP 200,并在 received 中看到 ceramic mug、learn HTTP、false 和 0;如果提示打不开文件,先确认第二个终端位于 api-lab。",
+        "explanation": "-X POST 选择发送方式,--data-binary 从文件读取请求体;Content-Type 告诉服务按 JSON 理解这些字节,-i 让你同时看到响应头。JSON 的键与字符串用双引号,false 用小写;价格 0 分表示免费教学样品。"
+      },
+      {
+        "title": "对照原始请求与服务器结果",
+        "body": "把 product.json 的 name 改为 canvas bag 并保存,再执行刚才的 curl 命令。对照下方协议示意找到请求体和 received;在浏览器 F12 → Network 中也可以查看刚执行的 /health 请求。",
+        "lang": "http",
+        "code": "POST /products HTTP/1.1\nHost: 127.0.0.1:8000\nContent-Type: application/json\n\n{\"name\":\"ceramic mug\",\"description\":\"learn HTTP\",\"is_active\":false, \"price_cents\": 0}\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"received\":{\"name\":\"ceramic mug\",\"description\":\"learn HTTP\",\"is_active\":false, \"price_cents\": 0}}",
+        "expected": "第二次响应中 name 变成 canvas bag。HTTP 状态仍为 200;它只确认回显完成,并没有生成商品 id 或证明商品已保存。",
+        "explanation": "请求体是发给服务的数据,响应体是服务返回的数据,长得相似也不是同一方向。Content-Type 描述实际格式,Accept 表达希望收到的格式;200 等状态属于 HTTP,不能靠在 JSON 里写一个 code 字段替代。"
+      }
+    ],
+    "checks": [
+      "能说明查询参数属于 URL,请求体位于请求头之后,两者不是同一块数据。",
+      "命令行 POST 返回 200,并在 received 中保留 product.json 的四个字段。",
+      "浏览器直接打开 /products 会发出 GET;当前只注册了 POST,因此应返回 405 Method Not Allowed。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "PowerShell 提示无法识别 -H 参数",
+        "cause": "使用了旧版 PowerShell 的 curl 别名。",
+        "fix": "明确执行 curl.exe;或者在 /docs 中操作。"
+      },
+      {
+        "symptom": "请求体解析失败",
+        "cause": "把 Python 字典写法当成 JSON,例如使用单引号或 False。",
+        "fix": "使用 product.json 文件,字符串和字段名用双引号,布尔值写 true / false。"
+      },
+      {
+        "symptom": "浏览器地址栏打不开 POST 接口",
+        "cause": "地址栏导航通常发送 GET,没有发送预期的 POST 和 JSON 请求体。",
+        "fix": "使用 curl、/docs 或能够配置 HTTP 方法的客户端。"
+      }
+    ],
+    "exercise": {
+      "task": "把请求体的 name 改成 second product,再发送一次。记录响应的 HTTP 状态码、Content-Type 和 received.name;最后说明 Accept 和 Content-Type 的区别。",
+      "hint": "使用 -i 查看响应头,不要只看最后一行 JSON。",
+      "answer": "状态码为 200;响应 Content-Type 为 application/json;received.name 为 second product。请求 Content-Type 描述这次发送的数据格式,Accept 描述客户端希望接收的格式。"
+    },
+    "refs": [
+      {
+        "label": "RFC 9110:HTTP 消息与内容语义",
+        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-6"
+      },
+      {
+        "label": "RFC 8259:JSON 语法",
+        "url": "https://www.rfc-editor.org/rfc/rfc8259"
+      },
+      {
+        "label": "FastAPI:直接使用 Request",
+        "url": "https://fastapi.tiangolo.com/advanced/using-request-directly/"
+      }
+    ],
+    "intro": {
+      "scenario": "接下来要把“陶瓷杯”从电脑发给服务。先做一个原样回信的接口,看到名称确实往返一次,再学习保存商品。",
+      "startingPoint": [
+        "已完成 API 第 01 课,在 api-lab 中能启动 main.py;浏览器 /health 显示 ok。",
+        "继续使用同一 .venv 和 8000 端口;保留服务终端,另开一个已进入 api-lab 的终端发请求。",
+        "本课会新建 product.json;POST 回显只用于看数据,不会把商品保存到列表。"
+      ],
+      "outcome": "发送包含陶瓷杯信息的请求,收到原样返回的数据,并能指出状态码和商品数据分别在哪里。"
+    }
+  },
+  {
+    "id": "03",
+    "group": "起步与 HTTP",
+    "title": "从路径、查询参数和 JSON 中接收数据",
+    "summary": "让服务接收商品编号、列表条件和商品 JSON,看到默认字段被补齐,并用错误位置找出填错的项。",
+    "objectives": [
+      "分别通过地址、问号后的条件和 JSON 发送输入",
+      "只填写商品名称,观察其余字段的默认值",
+      "输入错误编号、超界数量和缺失名称,读出具体错误位置"
+    ],
+    "concepts": [
+      {
+        "title": "三种位置,各有明确用途",
+        "body": "本课分别用 /products/12 指定编号,用 ?limit=5 控制读取条件,用 POST 的 JSON 描述商品。路径决定找谁,查询条件决定怎么读,请求体承载要提交的字段。"
+      },
+      {
+        "title": "声明规则后,先检查再执行",
+        "body": "abc 和 limit=0 都在第 2、3 步被拒绝,函数不需要重复手写转换和范围判断。422 的 loc 标识实际失败位置,是比猜错误原因更直接的线索。"
+      },
+      {
+        "title": "可省略、可为 null、取默认值是不同约定",
+        "body": "第 4 步省略描述后得到空串,省略价格后得到 0。is_active 查询条件省略时为 None;str | None 允许 null,但能否省略还取决于是否提供默认值。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "声明创建商品的输入模型",
+        "body": "在 api-lab/main.py 中,先删除第 02 课 inspect_product 函数及它上方的 @app.post 装饰器。把下面模型放在 app 创建之后、各路由函数之前;POST 会在第 4 步重新添加。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from pydantic import BaseModel, Field\n\nclass ProductCreate(BaseModel):\n    name: str\n    description: str = Field(default=\"\", max_length=16000)\n    is_active: bool = False\n    price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)",
+        "expected": "文件保存后可正常重载,/health 可访问;此时 /docs 暂时没有 POST /products 是正常的,因为我们先准备它的输入说明。",
+        "explanation": "ProductCreate 是一个输入模型:它集中说明一个商品 JSON 应有哪些字段。name 后没有默认值,所以必须提供;description、is_active、price_cents 有默认值。价格是整数分,1990 表示 19.90 元,strict=True 使价格不接受小数、字符串或布尔值。"
+      },
+      {
+        "title": "添加路径参数",
+        "body": "在 main.py 末尾追加下方 GET 路由,保存后在浏览器分别打开 /products/12 和 /products/abc。这里返回解析结果,暂不查找真实商品。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "@app.get(\"/products/{product_id}\")\ndef inspect_product_id(product_id: int):\n    return {\"product_id\": product_id}",
+        "expected": "/products/12 返回 {\"product_id\":12};/products/abc 返回 422,detail 中 loc 包含 path 和 product_id。",
+        "explanation": "{product_id} 是路径中预留的位置;函数参数 product_id: int 要求该位置能转为整数。abc 无法满足要求,框架在进入函数前就拒绝请求,这就是类型声明参与实际检查。"
+      },
+      {
+        "title": "添加查询参数和范围",
+        "body": "继续追加下方 GET /products 路由。依次访问 /products、/products?limit=5&is_active=false、/products?limit=0,记录三个响应。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from typing import Annotated\nfrom fastapi import Query\n\n@app.get(\"/products\")\ndef inspect_filters(\n    limit: Annotated[int, Query(ge=1, le=100)] = 10,\n    is_active: bool | None = None,\n):\n    return {\"limit\": limit, \"is_active\": is_active}",
+        "expected": "不写条件时返回 limit=10、is_active=null;第二次为 5 和 false;第三次返回 422,错误位置为 query 中的 limit。",
+        "explanation": "问号后的 key=value 是查询参数,多个条件用 & 连接。Annotated 将类型和 Query 范围写在一起;ge=1、le=100 限制上下界。None 表示没有给上架筛选条件,false 表示明确查下架商品,两者不能混用。"
+      },
+      {
+        "title": "用类型模型替换原始请求体",
+        "body": "在 main.py 中添加下方新的 POST /products,确保没有残留第二个同方法路由。刷新 /docs,在它的请求体框中只填 {\"name\":\"ceramic mug\"} 并 Execute。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "@app.post(\"/products\")\ndef inspect_product_body(product: ProductCreate):\n    return product.model_dump()",
+        "expected": "响应中除 name 外还出现 description=\"\"、is_active=false、price_cents=0;仍没有 id,且不会加入任何商品列表。",
+        "explanation": "product: ProductCreate 把刚才的字段规则接到了接口上,输入被解析后才交给函数。model_dump() 再把模型变回普通 Python 字典,因此你看到了已经补齐的默认值。"
+      },
+      {
+        "title": "建立有效值和无效值对照",
+        "body": "在 /docs 按下方请求清单执行四次,GET 的参数填写在相应框中,POST 的 {} 填到请求体框;清单是操作对照,不是 Shell 脚本。将状态和 loc 记到自己的笔记中。",
+        "lang": "http",
+        "code": "GET /products/12\n# 200: {\"product_id\":12}\n\nGET /products?limit=5&is_active=false\n# 200: {\"limit\":5,\"is_active\":false}\n\nGET /products?limit=0\n# 422,错误位置包含 [\"query\",\"limit\"]\n\nPOST /products\nContent-Type: application/json\n\n{}\n# 422,错误位置包含 [\"body\",\"name\"]",
+        "expected": "前两次返回 200,limit=0 和空商品对象返回 422;错误分别指向 query.limit 与 body.name,和路径填 abc 的 path.product_id 可以区分。",
+        "explanation": "相同的 422 可以来自不同输入位置,loc 帮你知道该改地址、查询条件还是 JSON。我们现在只检查了类型、默认值和数值范围;名称长度与多余字段下一课再逐项收紧。"
+      }
+    ],
+    "checks": [
+      "GET /products/12 返回数字 12,/products/abc 返回 422。",
+      "GET /products 返回 {\"limit\":10,\"is_active\":null};?is_active=false 返回 false。",
+      "POST /products 只传 name 时补齐 description、is_active 和 price_cents;缺少 name 或价格为负数、小数时返回 422。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "把 product_id 放进查询字符串,得到列表而不是单件商品",
+        "cause": "GET /products?product_id=12 匹配的是 GET /products。本课列表路由没有声明 product_id,FastAPI 默认忽略这个额外查询参数,因此返回 200 和列表;它不会匹配 /products/{product_id}。",
+        "fix": "读取单件商品时改为 GET /products/12。先检查方法和路径,再检查查询参数;只有确实没有匹配路由或资源不存在时才会得到相应的 404。"
+      },
+      {
+        "symptom": "写了 name: str | None 后,省略 name 仍然报错",
+        "cause": "允许 null 和允许省略是两个条件。",
+        "fix": "如果业务允许省略,显式写默认值 = None;本例 name 应保持必填字符串。"
+      },
+      {
+        "symptom": "修改 POST /products 后响应还像上一课",
+        "cause": "旧路由没有移除,重复注册了相同方法和路径。",
+        "fix": "搜索 @app.post('/products'),只保留当前一个定义,保存并确认服务完成重载。"
+      }
+    ],
+    "exercise": {
+      "task": "为 GET /products 添加 offset 查询参数,默认 0,只允许非负整数,并把解析结果加进响应。验证 offset=3 与 offset=-1。",
+      "hint": "参考 limit 的 Annotated[int, Query(...)],使用 ge=0。",
+      "answer": "在函数参数中加入 offset: Annotated[int, Query(ge=0)] = 0,并在返回字典中加入 'offset': offset。?offset=3 返回 200 且 offset 为 3;?offset=-1 返回 422,loc 包含 query、offset。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:路径参数",
+        "url": "https://fastapi.tiangolo.com/tutorial/path-params/"
+      },
+      {
+        "label": "FastAPI:查询参数",
+        "url": "https://fastapi.tiangolo.com/tutorial/query-params/"
+      },
+      {
+        "label": "FastAPI:请求体",
+        "url": "https://fastapi.tiangolo.com/tutorial/body/"
+      }
+    ],
+    "intro": {
+      "scenario": "陶瓷杯的数据已经能往返,但服务还没有区分“商品编号”“最多看几条”“商品名称”。现在让三类输入各自进入合适的位置,并试试填错时会怎样。",
+      "startingPoint": [
+        "已完成 API 第 02 课,api-lab/main.py 中有 /health 和临时 POST /products;product.json 可用于回显。",
+        "继续使用已激活的 .venv 和 /docs;本课会删除临时 POST 函数并换成新函数,其余课程尚未要求保存商品。"
+      ],
+      "outcome": "让服务接收商品编号、列表条件和商品 JSON,看到默认字段被补齐,并用错误位置找出填错的项。"
+    }
+  },
+  {
+    "id": "04",
+    "group": "起步与 HTTP",
+    "title": "用 Pydantic 定义输入和输出契约",
+    "summary": "让空名称和拼错字段被明确拒绝,让商品响应只包含五个公开字段,并区分输入错误与服务端输出错误。",
+    "objectives": [
+      "给商品名称加长度规则并拒绝陌生字段",
+      "规定返回给调用方的五个商品字段",
+      "分别制造输入与输出错误,修复后用 /docs 核对约定"
+    ],
+    "concepts": [
+      {
+        "title": "输入规则来自具体失败样例",
+        "body": "空名称触发 Field,is_actve 触发 extra=\"forbid\",价格保持非负整数分。第 1 步的对照证明这些规则确实执行,而不只是写在文档里。"
+      },
+      {
+        "title": "输出有单独的公开边界",
+        "body": "第 3 步内部字典有 internal_source,但响应没有;ProductOut 负责规定公开字段及其类型。输入不能自行指定 id,输出却必须有 id。"
+      },
+      {
+        "title": "422 与 500 指向不同修改者",
+        "body": "第 4 步同样是字段问题:调用方漏或错输入得到 422,服务器漏输出 id 得到 500。修复后再用正常请求验收,不能只看错误实验报错就结束。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "收紧 ProductCreate 的输入规则",
+        "body": "在 api-lab/main.py 中用下面完整定义替换旧 ProductCreate,保留其余路由。保存后在 /docs 分别发送 {\"name\":\"\"} 和 {\"name\":\"cup\",\"is_actve\":true}。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from pydantic import BaseModel, ConfigDict, Field\n\nclass ProductCreate(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    name: str = Field(min_length=1, max_length=120)\n    description: str = Field(default=\"\", max_length=16000)\n    is_active: bool = False\n    price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)",
+        "expected": "两次都返回 422:第一次指出 name 太短,第二次指出 is_actve 是多余字段;正常名称仍可以通过。",
+        "explanation": "Field 把长度要求写在字段旁;ConfigDict(extra=\"forbid\") 拒绝没有声明的键,使拼写错误不再被静默忽略。价格的严格整数要求只约束 price_cents,不会把其他字段自动变成严格模式。"
+      },
+      {
+        "title": "增加响应模型",
+        "body": "在 ProductCreate 后、各路由前添加 ProductOut。逐行对照输入模型,先找出多出来的 id;此步骤只定义返回格式,还没有接到接口。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "class ProductOut(BaseModel):\n    id: int\n    name: str\n    description: str = Field(max_length=16000)\n    is_active: bool\n    price_cents: int = Field(ge=0, le=2147483647, strict=True)",
+        "expected": "文件可重载;POST 暂时仍按上一课返回,因为 response_model 要到下一步才启用。ProductOut 明确列出 id 与四个可写字段。",
+        "explanation": "输入模型描述调用方填写的商品内容,输出模型还包含服务生成的编号。两份模型分别声明,避免把禁止额外输入的设置错误地带到下面的输出过滤实验。"
+      },
+      {
+        "title": "用 response_model 约束 POST 响应",
+        "body": "用下方装饰器和函数替换原 POST /products,在 /docs 发送 {\"name\":\"ceramic mug\"}。将函数返回字典与实际响应逐字段比较。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "@app.post(\"/products\", response_model=ProductOut)\ndef inspect_product_body(product: ProductCreate):\n    return {\n        \"id\": 1,\n        **product.model_dump(),\n        \"internal_source\": \"validation-lesson\",\n    }",
+        "expected": "收到 id=1、name、description、is_active、price_cents;代码中的 internal_source 没有出现在 HTTP 响应中。",
+        "explanation": "response_model=ProductOut 对返回字典先检查再筛选,只保留约定字段。id=1 是本课为了观察格式写出的固定值,还不是持久保存后的商品编号。"
+      },
+      {
+        "title": "分别制造输入与输出错误",
+        "body": "先在 /docs 发送下方空名称对象。随后临时删除 POST 返回字典中的 \"id\": 1,保存并用正常名称发请求,同时看服务终端。观察后立即恢复 id 并再次发正常请求。",
+        "lang": "json",
+        "code": "{\n  \"name\": \"\",\n  \"description\": \"name must not be empty\",\n  \"is_active\": false, \"price_cents\": 0\n}",
+        "expected": "空名称得到 422 且 loc 指向 body.name;缺 id 时正常请求得到 500,终端有响应校验异常;恢复 id 后重新得到 200。",
+        "explanation": "422 表示调用方提供的内容不合约定;本次 500 表示我们自己的返回代码漏字段,需要修服务器。把输出错误一律写成 422 会让调用方反复修改本来正确的商品内容。"
+      },
+      {
+        "title": "检查文档中的契约",
+        "body": "刷新 /docs,在页面底部 Schemas 和 POST /products 的响应说明中展开 ProductCreate、ProductOut,查看 name 的长度与 id 要求。发送 {\"name\":\"schema check\"},把下方示例作为字段对照保存。",
+        "lang": "json",
+        "code": "{\n  \"id\": 1,\n  \"name\": \"schema check\",\n  \"description\": \"\",\n  \"is_active\": false, \"price_cents\": 0\n}",
+        "expected": "响应恰好包含示例中的五个字段;名称规则和输出 id 也在文档中可见。若仍为 500,先确认上一步已恢复 id。",
+        "explanation": "同一份模型同时参与执行时检查和文档生成,所以规则修改会体现在两处。长度检查还不能判断名称是否有实际意义,例如单个空格;更细的业务规则需要另行明确。"
+      }
+    ],
+    "checks": [
+      "空 name 或额外 is_actve 字段均返回 422。",
+      "正常创建演示返回 id、name、description、is_active、price_cents 五个字段,不包含 internal_source。",
+      "故意漏掉输出 id 时出现 500;恢复后为 200,当前尚未设置创建成功的 201。",
+      "price_cents=1990 原样返回整数;-1、1.5、\"1990\"、true 和超过 2147483647 的值均返回 422。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "空格名称通过了 min_length=1",
+        "cause": "空格也是字符串字符,长度校验不执行去空格。",
+        "fix": "根据业务要求加入 strip_whitespace 或字段校验器,去空格后再检查是否为空。"
+      },
+      {
+        "symptom": "response_model 没有过滤某个自定义响应",
+        "cause": "直接返回 JSONResponse 等 Response 对象时,框架不会按普通返回值继续执行响应模型转换。",
+        "fix": "本节返回字典或模型对象,让 FastAPI 使用 response_model 处理。"
+      },
+      {
+        "symptom": "把模型直接拼到字典时提示类型不匹配",
+        "cause": "Pydantic 模型对象不等于普通字典。",
+        "fix": "需要字典时调用 product.model_dump();不要使用旧教材中的 .dict() 作为本模块标准写法。"
+      }
+    ],
+    "exercise": {
+      "task": "拒绝只有空白的名称,并将 '  ceramic mug  ' 保存为 'ceramic mug'。保持 120 字符上限。",
+      "hint": "为 name 添加 mode='before' 的 field_validator,先去除字符串两端空白,再交给 Field 长度校验。",
+      "answer": "从 pydantic 导入 field_validator,在 ProductCreate 内添加:\n@field_validator('name', mode='before')\n@classmethod\ndef strip_name(cls, value):\n    return value.strip() if isinstance(value, str) else value\n\n只含空格会变为空字符串并返回 422;'  ceramic mug  ' 经解析后为 'ceramic mug'。"
+    },
+    "refs": [
+      {
+        "label": "Pydantic:Field 与字段约束",
+        "url": "https://docs.pydantic.dev/latest/concepts/fields/"
+      },
+      {
+        "label": "Pydantic:验证器",
+        "url": "https://docs.pydantic.dev/latest/concepts/validators/"
+      },
+      {
+        "label": "FastAPI:响应模型",
+        "url": "https://fastapi.tiangolo.com/tutorial/response-model/"
+      }
+    ],
+    "intro": {
+      "scenario": "目前空名称和拼错的商品字段还可能被接受,内部调试字段也可能混进返回结果。我们用几次故意填错的请求,逐步确定“允许送什么、应该收到什么”。",
+      "startingPoint": [
+        "已完成 API 第 03 课,main.py 中 ProductCreate 和三种输入练习路由可运行。",
+        "在 /docs 只提交 {\"name\":\"ceramic mug\"},先确认能收到默认描述、上架状态和价格;本课仍使用演示 id,不保存商品。"
+      ],
+      "outcome": "让空名称和拼错字段被明确拒绝,让商品响应只包含五个公开字段,并区分输入错误与服务端输出错误。"
+    }
+  },
+  {
+    "id": "05",
+    "group": "REST 接口",
+    "title": "设计商品资源与 HTTP 行为",
+    "summary": "写出五种商品操作的请求清单,区分不存在的地址和不支持的方法,并准备完整替换所需的四个字段。",
+    "objectives": [
+      "为列表、单件商品、创建、替换、删除各写一个请求",
+      "用实际请求比较 404 与 405",
+      "验证完整替换必须同时填写四个可写字段"
+    ],
+    "concepts": [
+      {
+        "title": "方法与地址共同表达操作",
+        "body": "第 1 步同一个集合地址对应 GET 和 POST,第 2 步同一路径换方法得到 405。只看 URL 无法确定完整接口。"
+      },
+      {
+        "title": "完整替换用明确必填字段",
+        "body": "第 3 步少填三项时模型拒绝请求;这与创建模型补默认值的行为不同。本课程 PUT 替换四个可写字段,PATCH 式局部修改不在本课范围内。"
+      },
+      {
+        "title": "重复操作要看最终状态",
+        "body": "第 4 步设计的两次 PUT 应留下同一内容,两次 DELETE 都留下“不存在”。成功读取与更新用 200,创建用 201,空体删除用 204,错误仍用相应的 4xx 状态。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "写下接口清单",
+        "body": "在 api-lab 新建 products-contract.http,记录下方目标清单。先为每行口头补出“操作哪个商品、提供哪些字段、如何确认结果”;这份清单供第 06 课逐项实现,现在不要期待未实现的 PUT 和 DELETE 成功。",
+        "filename": "products-contract.http",
+        "lang": "http",
+        "code": "GET    /products              # 查询集合,200,响应为数组\nPOST   /products              # 创建,201,响应包含新 id\nGET    /products/{product_id}    # 查询单条,200 或 404\nPUT    /products/{product_id}    # 完整替换,200 或 404\nDELETE /products/{product_id}    # 删除,204 或 404",
+        "expected": "清单有五行,集合地址与带 product_id 的单件地址清楚分开;POST 目标是 201,DELETE 目标是 204,和当前回显阶段有所不同。",
+        "explanation": "方法与路径一起确定操作:同一个 /products 可以分别用于 GET 列表和 POST 创建。把输入与成功状态一并写下,形成双方都能验收的接口约定,也常称为接口契约。"
+      },
+      {
+        "title": "用已有服务区分 404 与 405",
+        "body": "在第二个终端执行下方三条 curl 命令;Windows PowerShell 把 curl 写成 curl.exe。先记状态码,再在第二次响应里找 Allow。",
+        "lang": "bash",
+        "code": "curl -i http://127.0.0.1:8000/health\ncurl -i -X POST http://127.0.0.1:8000/health\ncurl -i http://127.0.0.1:8000/missing-path",
+        "expected": "三次依次得到 200、405、404;405 响应有 Allow 头,说明 /health 支持哪些方法。",
+        "explanation": "404 表示地址没找到,405 表示地址存在但请求方式不支持。两次失败都发生在商品字段检查之外;JSON 里返回一个数字字段不能代替真正的 HTTP 状态。"
+      },
+      {
+        "title": "为完整替换定义独立输入",
+        "body": "在 main.py 模型区追加 ProductReplace 并保存。另开终端进入 api-lab、激活 .venv,执行 python 进入 Python 提示符;依次输入代码注释中的 from main import ProductReplace 和两次 ProductReplace(...) 调用,不复制开头的 #。最后 exit() 退出。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "class ProductReplace(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    name: str = Field(min_length=1, max_length=120)\n    description: str = Field(max_length=16000)\n    is_active: bool\n    price_cents: int = Field(ge=0, le=2147483647, strict=True)\n\n# 在独立 Python 终端中测试:\n# from main import ProductReplace\n# ProductReplace(name=\"updated\", description=\"\", is_active=False, price_cents=0)  # 通过\n# ProductReplace(name=\"updated\")  # ValidationError:缺少三项",
+        "expected": "填写四个字段时得到一个模型对象;只填 name 时出现 ValidationError,指出缺少 description、is_active、price_cents。此时尚无 PUT 路由。",
+        "explanation": "创建时可以使用默认值,而完整替换必须说明所有可写字段的新值。ProductReplace 不给这四项默认值,避免省略描述时被误认为保留旧描述;id 仍来自路径并保持不变。"
+      },
+      {
+        "title": "准备替换与删除的验收请求",
+        "body": "把下面完整 JSON 保存为 api-lab/replace.json。另写验收顺序:创建并记录返回 id → 同样 PUT 两次 → 同样 DELETE 两次。实际执行留到下一课,届时将路径编号换成真实返回值。",
+        "filename": "replace.json",
+        "lang": "json",
+        "code": "{\n  \"name\": \"updated ceramic mug\",\n  \"description\": \"replace all editable fields\",\n  \"is_active\": true, \"price_cents\": 0\n}",
+        "expected": "replace.json 包含 name、description、is_active、price_cents 四项;验收笔记说明重复 PUT 内容应一致,第二次 DELETE 的目标仍应不存在。",
+        "explanation": "这就是幂等的关注点:重复操作后的目标状态相同,不要求响应完全一样。删除可以先返回 204 后返回 404;204 没有响应体,不能继续按 JSON 解析。重复 POST 则可能多建商品。"
+      }
+    ],
+    "checks": [
+      "GET /health 是 200;POST /health 是 405;不存在的路径是 404。",
+      "ProductReplace 的 name、description、is_active、price_cents 四个可写字段都必须提交;空 description、false 和 0 分本身合法。",
+      "能解释 DELETE 第二次返回 404 仍然满足幂等,以及 204 为什么不能带 {\"ok\":true}。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "PUT 请求只传 name,意外把 description 清空",
+        "cause": "复用了带默认值的创建模型,未明确全量替换规则。",
+        "fix": "使用所有可编辑字段均必填的 ProductReplace;如果需要局部更新,应另行设计 PATCH。"
+      },
+      {
+        "symptom": "客户端对成功 DELETE 调用 response.json() 后报错",
+        "cause": "204 响应没有响应体。",
+        "fix": "先判断状态码;遇到 204 直接按成功处理,不执行 JSON 解码。"
+      },
+      {
+        "symptom": "网络超时后重复 POST,出现两件商品",
+        "cause": "客户端没收到响应,并不等于服务器没创建资源。",
+        "fix": "本地实验先查询列表核对;生产创建接口需根据业务设计幂等键等重复提交控制,不能直接假定 POST 可安全重试。"
+      }
+    ],
+    "exercise": {
+      "task": "评审三个接口:GET /delete-product?id=1、PUT /products/1 只接收 is_active、DELETE /products/1 返回 200 和 {\"deleted\":true}。给出本课程约定下的修改方案,并说明第三种设计是否违反 HTTP。",
+      "hint": "分清 HTTP 协议允许的行为与课程选择的统一接口约定。",
+      "answer": "第一种改为 DELETE /products/1,GET 不用于用户请求的数据删除。第二种若只做局部修改应定义 PATCH,或者要求 PUT 传齐 name/description/is_active。第三种返回 200 和结果体在 HTTP 中可以成立;本课程统一选择 204 空体,因此需同时调整状态码和响应内容。"
+    },
+    "refs": [
+      {
+        "label": "RFC 9110:请求方法",
+        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9"
+      },
+      {
+        "label": "RFC 9110:204 No Content",
+        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.5"
+      },
+      {
+        "label": "FastAPI:更新请求体",
+        "url": "https://fastapi.tiangolo.com/tutorial/body-updates/"
+      }
+    ],
+    "intro": {
+      "scenario": "商品信息已经能检查了,但“创建一件商品”和“修改已有商品”还没有明确区别。先把查看、创建、替换、删除的操作约定写清,再进入真正保存数据的实现。",
+      "startingPoint": [
+        "已完成 API 第 04 课,main.py 中有 ProductCreate、ProductOut,正常 POST 仍回传演示 id=1。",
+        "api-lab 的服务正在运行,GET /health 为 200;本课准备请求文件,不把回显当成已经保存的商品。"
+      ],
+      "outcome": "写出五种商品操作的请求清单,区分不存在的地址和不支持的方法,并准备完整替换所需的四个字段。"
+    }
+  },
+  {
+    "id": "06",
+    "group": "REST 接口",
+    "title": "完成内存版商品 CRUD",
+    "summary": "在同一次服务运行中完成商品创建、查询、完整替换和删除,并从实际响应追踪同一个商品编号。",
+    "objectives": [
+      "用同一份共享字典保存请求之间的商品数据",
+      "实现五项商品操作及对应状态码",
+      "按返回 id 验证修改和删除结果,并观察重启后数据的边界"
+    ],
+    "concepts": [
+      {
+        "title": "共享状态让操作串成完整过程",
+        "body": "第 5 步创建后能再次查询、更新后能读取新值,是 products 字典跨请求存在的结果。它只属于当前进程,重载和多进程都不等于共享持久存储。"
+      },
+      {
+        "title": "编号独立于当前商品数量",
+        "body": "第 5 步删除后再次创建得到更大的编号。next_id 递增且不回退,客户端始终使用创建响应中的 id;不能用 len(products)+1 替代。"
+      },
+      {
+        "title": "结果要用下一次请求核实",
+        "body": "本课创建 201 后用 GET 确认,替换后再读四个字段,删除 204 后再读得到 404。这些后续观察比仅看到一次“成功”更能证明接口行为。"
+      },
+      {
+        "title": "当前内存实现有明确运行条件",
+        "body": "第 4 步使用单个 Uvicorn 进程,异步路由内部不等待,字典修改在一次执行段中完成。不要在共享状态更新中途随意加入 await;第 09 课会观察等待对执行的影响。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "重建 main.py 的公共部分",
+        "body": "用下面完整代码替换 api-lab/main.py,再按后面三个代码步骤逐段追加。不要保留此前回显函数;在第 4 步之前先不启动服务。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from typing import Annotated\n\nfrom fastapi import FastAPI, HTTPException, Query, Response\nfrom pydantic import BaseModel, ConfigDict, Field\n\napp = FastAPI(title=\"API Lab\")\n\nclass ProductCreate(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    name: str = Field(min_length=1, max_length=120)\n    description: str = Field(default=\"\", max_length=16000)\n    is_active: bool = False\n    price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)\n\nclass ProductReplace(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    name: str = Field(min_length=1, max_length=120)\n    description: str = Field(max_length=16000)\n    is_active: bool\n    price_cents: int = Field(ge=0, le=2147483647, strict=True)\n\nclass ProductOut(BaseModel):\n    id: int\n    name: str\n    description: str = Field(max_length=16000)\n    is_active: bool\n    price_cents: int = Field(ge=0, le=2147483647, strict=True)\n\nproducts: dict[int, dict] = {}\nnext_id = 1\n\n@app.get(\"/health\", tags=[\"system\"])\nasync def health():\n    return {\"status\": \"ok\"}",
+        "expected": "main.py 中有三个模型、空的 products 字典、next_id=1 和 /health。此时尚未写完五个商品操作,文件内容与下方起始代码一致即可。",
+        "explanation": "products 放在函数外,使下一次请求还能读到上一次写入;键是编号,值是商品字段。next_id 独立计数,避免用商品数量分配编号时因删除而与现存编号冲突。"
+      },
+      {
+        "title": "实现列表与创建",
+        "body": "把下面两个集合路由追加到 main.py。对照前一课清单检查路径与状态码;等第 4 步全部完成后再发请求。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "@app.get(\"/products\", response_model=list[ProductOut], tags=[\"products\"])\nasync def list_products(\n    limit: Annotated[int, Query(ge=1, le=100)] = 10,\n    is_active: bool | None = None,\n):\n    rows = list(products.values())\n    if is_active is not None:\n        rows = [row for row in rows if row[\"is_active\"] == is_active]\n    return rows[:limit]\n\n@app.post(\"/products\", response_model=ProductOut, status_code=201, tags=[\"products\"])\nasync def create_product(product: ProductCreate, response: Response):\n    global next_id\n    product_id = next_id\n    next_id += 1\n    row = {\"id\": product_id, **product.model_dump()}\n    products[product_id] = row\n    response.headers[\"Location\"] = f\"/products/{product_id}\"\n    return row",
+        "expected": "文件里有 GET /products 与 POST /products,POST 标明 201,保存新商品时既写字典又增加 next_id;不存在第二份同方法的回显路由。",
+        "explanation": "创建从输入模型取出所有字段,加入新 id 后存入共享字典,Location 头告诉客户端新商品的路径。列表筛选用 is_active is not None,保证 false 也会筛选,不能把 false 当成没传条件。"
+      },
+      {
+        "title": "实现单条读取与全量替换",
+        "body": "继续在 main.py 追加查找函数、GET 单件和 PUT 路由。留意 PUT 使用 ProductReplace,保留四个必填字段;不要把旧商品字段合并进新字典。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "def require_product(product_id: int) -> dict:\n    if product_id not in products:\n        raise HTTPException(status_code=404, detail=\"商品不存在\")\n    return products[product_id]\n\n@app.get(\"/products/{product_id}\", response_model=ProductOut, tags=[\"products\"])\nasync def get_product(product_id: int):\n    return require_product(product_id)\n\n@app.put(\"/products/{product_id}\", response_model=ProductOut, tags=[\"products\"])\nasync def replace_product(product_id: int, product: ProductReplace):\n    require_product(product_id)\n    row = {\"id\": product_id, **product.model_dump()}\n    products[product_id] = row\n    return row",
+        "expected": "单件读取和替换都先调用 require_product;PUT 以路径 id 和完整新字段重新写字典。尚未出现的 DELETE 下一步添加。",
+        "explanation": "require_product 将“找不到就结束请求”集中到一处;raise HTTPException(404,...) 会中断后续代码。PUT 先检查存在,再整体替换可编辑字段,id 因取自路径而保持不变。"
+      },
+      {
+        "title": "实现无响应体的删除",
+        "body": "追加下方 DELETE 路由后保存。在 api-lab 终端运行 python -m uvicorn main:app --reload,等待 startup complete,再打开 /docs。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "@app.delete(\"/products/{product_id}\", status_code=204, tags=[\"products\"])\nasync def delete_product(product_id: int):\n    require_product(product_id)\n    del products[product_id]\n    return Response(status_code=204)",
+        "expected": "文档出现 /health 加五个商品操作,共六个路由方法。启动若报模型未定义,检查代码是否按前面顺序拼入同一文件。",
+        "explanation": "删除先查存在再从字典移除;Response(status_code=204) 明确创建没有内容的响应,而不是返回一个会被转成 JSON 的值。本课 async 函数中没有 await,单进程字典修改会在一次执行段内完成。"
+      },
+      {
+        "title": "按顺序完成一轮 CRUD",
+        "body": "在 /docs 依次执行:GET /products → 用下面 JSON 做 POST → 记下返回 id → GET /products/{id} → PUT 提交 {\"name\":\"updated\",\"description\":\"\",\"is_active\":true,\"price_cents\":2090} → 再 GET → DELETE → 再 GET。期间不要改代码。最后再创建一件,比较新编号。",
+        "lang": "json",
+        "code": "{\n  \"name\": \"陶瓷杯\",\n  \"description\": \"350ml,白色\",\n  \"is_active\": false,\n  \"price_cents\": 1990\n}",
+        "expected": "初始列表为 [];创建为 201 且含真实 id;修改后同 id 的价格为 2090、上架为 true、描述为空串;删除为 204 且无内容,再查为 404;新建编号不会因刚才删除而回退。",
+        "explanation": "这次能在后续 GET 读到修改,才证明同一进程内的保存生效。客户端从创建响应读取 id,不假设永远为 1。价格用分,2090 表示 20.90 元;内存字典仍会因重启清空,持久保存将由数据库模块解决。"
+      }
+    ],
+    "checks": [
+      "空列表返回 200 和 [];创建返回 201,响应含整数 id,Location 指向 /products/{id}。",
+      "PUT 传齐字段时返回 200,id 保持不变;仅传 name 时返回 422。",
+      "删除返回 204 且响应体为空;再 GET 或 DELETE 相同 id 均返回 404。",
+      "创建两条并删除其中一条后,再创建不会覆盖另一条;服务重启后列表重新为空。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "保存代码后刚创建的商品找不到了",
+        "cause": "--reload 重启了服务进程,内存字典被重新初始化。",
+        "fix": "完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。"
+      },
+      {
+        "symptom": "筛选 is_active=false 返回全部数据",
+        "cause": "使用 if is_active 判断是否筛选,False 被当成未提供条件。",
+        "fix": "使用 if is_active is not None,再比较字段的布尔值。"
+      },
+      {
+        "symptom": "删除一条后,新商品覆盖了旧商品",
+        "cause": "用 len(products)+1 计算编号导致重复。",
+        "fix": "独立维护递增 next_id,不因删除回退;本地练习保持单进程运行。"
+      }
+    ],
+    "exercise": {
+      "task": "在列表接口加入 offset>=0,先按 is_active 筛选,再返回 rows[offset:offset+limit]。创建三条商品,第二条和第三条设为 is_active=true,验证 is_active=true&offset=1&limit=1 只返回第三条。",
+      "hint": "分页应该作用在筛选后的结果上;把切片放在筛选后,limit 和 offset 均添加参数约束。",
+      "answer": "添加 offset: Annotated[int, Query(ge=0)] = 0;保持 is_active 筛选顺序;把 return rows[:limit] 改为 return rows[offset:offset + limit]。按题目创建顺序,返回数组长度为 1,元素为第三条商品;offset=-1 返回 422。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:响应状态码",
+        "url": "https://fastapi.tiangolo.com/tutorial/response-status-code/"
+      },
+      {
+        "label": "FastAPI:直接返回 Response",
+        "url": "https://fastapi.tiangolo.com/advanced/response-directly/"
+      },
+      {
+        "label": "FastAPI:处理错误",
+        "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
+      }
+    ],
+    "intro": {
+      "scenario": "之前收到的商品只是回显,换一次请求就无处查找。现在真正做一个小目录:添加陶瓷杯、查出来、改价格和上架状态,最后删除它。",
+      "startingPoint": [
+        "已完成 API 第 05 课,理解五项操作清单,手边有 replace.json;仍使用 api-lab/.venv。",
+        "先停止 Uvicorn,将 main.py 另存为 main_before_crud.py 留作对照。接下来按第 1–4 步重建同一个 main.py,完成后再启动。",
+        "本课只用一个 Uvicorn 服务进程,数据放在内存。请求验收期间不保存代码、不重启;重载会清空商品。"
+      ],
+      "outcome": "在同一次服务运行中完成商品创建、查询、完整替换和删除,并从实际响应追踪同一个商品编号。"
+    }
+  },
+  {
+    "id": "07",
+    "group": "REST 接口",
+    "title": "返回可定位、格式一致的错误",
+    "summary": "让找不到商品、输入不合法和方法不支持都返回一致的 error 外层,并保留状态码、字段位置与必要响应头。",
+    "objectives": [
+      "从已存在的 404 开始统一错误格式",
+      "让 422 同时告诉调用方具体哪一项输入有问题",
+      "用不存在、填错和方法错误三组请求验证变化"
+    ],
+    "concepts": [
+      {
+        "title": "判断失败与输出格式可以分工",
+        "body": "第 1 步 require_product 负责发现不存在,第 2 步处理器负责生成统一响应。raise 结束当前请求,不需要每个路由重新拼一份错误 JSON。"
+      },
+      {
+        "title": "统一结构仍保留差异",
+        "body": "验收里 404、405、422 没有变成同一个状态。error 给出固定外层,code 区分错误类别,details.loc 指出路径或字段,Allow 继续说明允许的方法。"
+      },
+      {
+        "title": "输入失败在路由之前发生",
+        "body": "第 3 步 abc 与缺 name 都由 RequestValidationError 处理;在商品函数内部加 try/except 接不到这类解析失败。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "确认已有业务错误入口",
+        "body": "在 main.py 找到下方 require_product,与现有函数对照即可,不要重复添加。先在 /docs 读取一个确认不存在的编号,保存默认错误响应作比较。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "def require_product(product_id: int) -> dict:\n    if product_id not in products:\n        raise HTTPException(status_code=404, detail=\"商品不存在\")\n    return products[product_id]",
+        "expected": "修改前响应状态是 404,内容通常为 {\"detail\":\"商品不存在\"},函数中使用的是 raise。",
+        "explanation": "raise 会立即结束当前处理,把异常交给框架;return 一个异常对象只是返回数据,无法表达同样的中断。我们保留判断位置,只改变出口格式。"
+      },
+      {
+        "title": "统一 HTTP 错误处理",
+        "body": "把下面导入与异常处理函数加入 main.py,放在 app 创建之后。保存后重发刚才的不存在商品请求。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\n@app.exception_handler(StarletteHTTPException)\nasync def handle_http_error(request: Request, exc: StarletteHTTPException):\n    return JSONResponse(\n        status_code=exc.status_code,\n        headers=exc.headers,\n        content={\n            \"error\": {\n                \"code\": \"http_error\",\n                \"message\": str(exc.detail),\n                \"details\": [],\n            }\n        },\n    )",
+        "expected": "仍是 404,但响应变为 error 对象,其中 code=http_error、message=商品不存在、details=[]。",
+        "explanation": "exception_handler 注册应用层的错误出口;使用 StarletteHTTPException 基类还能接住框架产生的 404、405。status_code 和 headers 从原异常保留,格式统一不应破坏 HTTP 含义。"
+      },
+      {
+        "title": "统一 422 并保留字段位置",
+        "body": "继续添加下方请求校验处理器。保存后在 /docs 对 POST /products 发送 {},再 GET /products/abc。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from fastapi.exceptions import RequestValidationError\n\n@app.exception_handler(RequestValidationError)\nasync def handle_validation_error(request: Request, exc: RequestValidationError):\n    details = [\n        {\n            \"loc\": list(error[\"loc\"]),\n            \"type\": error[\"type\"],\n            \"msg\": error[\"msg\"],\n        }\n        for error in exc.errors()\n    ]\n    return JSONResponse(\n        status_code=422,\n        content={\n            \"error\": {\n                \"code\": \"validation_error\",\n                \"message\": \"请求参数不符合接口要求\",\n                \"details\": details,\n            }\n        },\n    )",
+        "expected": "两次仍是 422,error.code 为 validation_error;details 中分别能看到 body.name 与 path.product_id 的位置。",
+        "explanation": "输入解析失败在路由运行前发生,所以要用 RequestValidationError 的应用处理器。这里只提取 loc、type、msg,既提供定位信息,又避免把原始输入或不能编码的异常上下文直接回显。"
+      },
+      {
+        "title": "用三类失败验证处理器",
+        "body": "在 /docs 重测不存在商品、abc 编号和空 POST;另在终端发送 curl -i -X POST http://127.0.0.1:8000/health(Windows 用 curl.exe)。下方 JSON 是空 POST 的响应示例,只用于对照。",
+        "lang": "json",
+        "code": "{\n  \"error\": {\n    \"code\": \"validation_error\",\n    \"message\": \"请求参数不符合接口要求\",\n    \"details\": [\n      {\n        \"loc\": [\"body\", \"name\"],\n        \"type\": \"missing\",\n        \"msg\": \"Field required\"\n      }\n    ]\n  }\n}",
+        "expected": "404 与 405 的 code 都为 http_error,422 为 validation_error;所有响应都有 error,405 的 Allow 头仍存在。英文 msg 可能与示例略有不同。",
+        "explanation": "外层一致方便界面统一显示,状态码和 details 又保留了不同失败原因。程序适合按稳定的 code 与 loc 判断,不能依赖可能随库版本变化的英文说明;未知服务器错误仍应修代码,不能包装成 200。"
+      }
+    ],
+    "checks": [
+      "不存在的商品返回 404、http_error 和“商品不存在”。",
+      "非法 product_id 与缺失 name 都返回 422、validation_error,details.loc 能指出不同位置。",
+      "POST /health 仍返回 405,Allow 响应头被保留。",
+      "成功的商品响应不被包在 error 字段中;204 删除仍然没有响应体。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "错误响应有 message,但 HTTP 状态一直是 200",
+        "cause": "只返回了错误字典,没有设置 JSONResponse 的 status_code。",
+        "fix": "在异常处理器中显式设置对应状态,HTTP 错误使用 exc.status_code。"
+      },
+      {
+        "symptom": "注册 HTTPException 处理器后,未知路径仍是默认格式",
+        "cause": "只处理 FastAPI 的异常子类,没有覆盖框架抛出的 Starlette 基类异常。",
+        "fix": "导入并为 StarletteHTTPException 注册处理器,路由仍可抛出 FastAPI 的 HTTPException。"
+      },
+      {
+        "symptom": "自定义名称校验失败后出现 JSON 序列化异常",
+        "cause": "把 exc.errors() 的 ctx 异常对象直接放进 JSONResponse。",
+        "fix": "只提取 loc、type、msg 等必要字段,不直接返回任意异常对象或原始请求内容。"
+      }
+    ],
+    "exercise": {
+      "task": "对 GET /products?limit=0、GET /products/abc、POST /products 的 {} 逐一记录 status、error.code 和第一条 details.loc,写出客户端应如何定位表单或请求参数错误。",
+      "hint": "三次都是 422,但错误位置的来源不同:query、path、body。",
+      "answer": "三次 status 都是 422,code 都是 validation_error。loc 分别为 ['query','limit']、['path','product_id']、['body','name']。客户端先按 status/code 判断错误类别,再用 loc 映射对应输入位置;msg 用于显示,不作为稳定的判断条件。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:覆盖异常处理器",
+        "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/"
+      },
+      {
+        "label": "Starlette:Exceptions",
+        "url": "https://github.com/Kludex/starlette/blob/main/docs/exceptions.md"
+      },
+      {
+        "label": "Pydantic:错误处理",
+        "url": "https://docs.pydantic.dev/latest/errors/errors/"
+      }
+    ],
+    "intro": {
+      "scenario": "查询不存在的杯子和提交空名称都会失败,但默认返回的形状不一样。我们让调用方始终能在相同位置找到错误,同时保留该改哪个字段的线索。",
+      "startingPoint": [
+        "已完成 API 第 06 课的完整 main.py,创建、删除商品与删除后 404 均已验证。",
+        "继续 api-lab 和 /docs;先访问 /products/999999,确认这个编号确实不存在,若已存在则换一个未使用的正整数。"
+      ],
+      "outcome": "让找不到商品、输入不合法和方法不支持都返回一致的 error 外层,并保留状态码、字段位置与必要响应头。"
+    }
+  },
+  {
+    "id": "08",
+    "group": "REST 接口",
+    "title": "复用依赖与拆分路由文件",
+    "summary": "在独立文件实现 /private,并看到缺少、错误、正确校验值分别被拒绝或放行。",
+    "objectives": [
+      "把共同的请求检查写成一个函数",
+      "将独立路由文件接到现有 app",
+      "设置服务读取的配置值并用三次请求验收"
+    ],
+    "concepts": [
+      {
+        "title": "依赖是实际执行的前置动作",
+        "body": "第 5 步三次请求经过同一个 require_demo_key;前两次抛异常,路由不执行,第三次才返回成功。Depends 让这份规则可以复用到多个接口。"
+      },
+      {
+        "title": "请求头与服务配置来自两边",
+        "body": "第 4 步设置的 DEMO_API_KEY 属于服务进程,第 5 步填写的 X-API-Key 属于来访请求。把值放在查询参数里不会满足 Header 声明。"
+      },
+      {
+        "title": "文件组织还需要注册",
+        "body": "第 2 步建立 private.py 后接口尚不可用,第 3 步 include_router 后才出现在 /docs。多个文件最终仍由同一个 app 提供服务和错误处理。"
+      },
+      {
+        "title": "课堂校验不等于用户身份系统",
+        "body": "这次只验证一个公开课堂字符串,没有用户、权限分组和密钥轮换。它帮助理解配置、前置检查和请求头,业务认证需要另定方案。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立依赖函数文件",
+        "body": "在 api-lab 根目录新建 dependencies.py,粘贴下方完整代码。先保留 main.py 的商品操作和错误处理器,这一步还不访问 /private。",
+        "filename": "dependencies.py",
+        "lang": "python",
+        "code": "import os\nimport secrets\nfrom typing import Annotated\n\nfrom fastapi import Header, HTTPException\n\ndef require_demo_key(\n    x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n) -> None:\n    expected = os.getenv(\"DEMO_API_KEY\")\n    if not expected:\n        raise HTTPException(status_code=503, detail=\"服务未配置 DEMO_API_KEY\")\n    if x_api_key is None or not secrets.compare_digest(\n        x_api_key.encode(\"utf-8\"), expected.encode(\"utf-8\")\n    ):\n        raise HTTPException(\n            status_code=401,\n            detail=\"X-API-Key 无效\",\n            headers={\"WWW-Authenticate\": \"APIKey\"},\n        )",
+        "expected": "dependencies.py 保存后没有顶层执行输出;文件中包含 require_demo_key,main.py 仍可运行。下一步会让新接口真正调用它。",
+        "explanation": "函数先读取服务端环境变量,再读取请求头并比较。未配置时返回 503,表示服务还没准备好;配置了但来访者没带正确值时返回 401。compare_digest 用于比较校验值,Header(alias=...) 指明数据来自请求头。"
+      },
+      {
+        "title": "创建独立的私有路由",
+        "body": "新建 api-lab/routers 目录,在其中创建空的 __init__.py 和下方 private.py。注意 import 从 dependencies 读函数,文件内使用 @router.get。",
+        "filename": "routers/private.py",
+        "lang": "python",
+        "code": "from fastapi import APIRouter, Depends\n\nfrom dependencies import require_demo_key\n\nrouter = APIRouter(\n    tags=[\"private\"],\n    dependencies=[Depends(require_demo_key)],\n)\n\n@router.get(\"/private\")\ndef read_private():\n    return {\"message\": \"API key accepted\"}",
+        "expected": "routers 目录有两个文件;此时只保存文件还不会让 /docs 出现 /private,这是下一步注册的工作。",
+        "explanation": "APIRouter 先在独立模块收集路由;Depends(require_demo_key) 声明每次访问前先执行检查。检查抛异常时不会进入 read_private,因此多个入口可共用同一前置步骤。"
+      },
+      {
+        "title": "在主入口注册 router",
+        "body": "在 main.py 顶部导入 private,在已有 app = FastAPI(...) 之后加入 include_router;不要新建第二个 app。保存、等待重载并刷新 /docs,展开 private 分组。",
+        "filename": "main.py",
+        "lang": "python",
+        "code": "from routers import private\n\n# 放在 app = FastAPI(...) 之后\napp.include_router(private.router)\n\n# 目录结构\n# api-lab/\n#   main.py\n#   dependencies.py\n#   routers/\n#     __init__.py\n#     private.py",
+        "expected": "/docs 中出现 GET /private 和可填写的 X-API-Key;商品接口及统一错误处理仍在。若没有新路由,核对 include_router 是否执行。",
+        "explanation": "导入 Python 模块不等于注册接口,include_router 才把收集的路由加入同一个应用。独立文件不反向 import main,可以避免循环导入;应用级错误处理器也会作用于新路由。"
+      },
+      {
+        "title": "设置当前终端环境并重新启动",
+        "body": "在运行服务的终端按 Ctrl+C。只执行下方与你系统对应的一组命令,在同一个窗口设置变量再启动;不要在另一终端设置后直接刷新旧服务。",
+        "lang": "bash",
+        "code": "# Windows PowerShell\n$env:DEMO_API_KEY = \"classroom-key\"\npython -m uvicorn main:app --reload\n\n# macOS / Linux\nexport DEMO_API_KEY=\"classroom-key\"\npython -m uvicorn main:app --reload",
+        "expected": "服务重新启动后不再因未配置 DEMO_API_KEY 返回 503。先访问不带头的 /private,应得到 401。",
+        "explanation": "新进程会继承启动它的终端环境,这解释了为什么必须在设置值后重新启动。classroom-key 是服务端期望的课堂值,请求还需要从 X-API-Key 头带来匹配值。"
+      },
+      {
+        "title": "验证缺失、错误与正确密钥",
+        "body": "在 /docs 依次执行 GET /private:不填 X-API-Key、填 wrong-key、填 classroom-key。也可用下面三条 curl 对照,Windows 用 curl.exe。最后访问 GET /products。",
+        "lang": "bash",
+        "code": "# macOS / Linux;Windows PowerShell 使用 curl.exe\ncurl -i http://127.0.0.1:8000/private\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: wrong-key\"\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: classroom-key\"",
+        "expected": "前两次为 401,第三次为 200 和 API key accepted;错误使用第 07 课的 error 格式,401 保留 WWW-Authenticate;商品列表仍可按原方式访问。",
+        "explanation": "同一检查函数在每次私有请求前执行,只有通过才进入路由。Header 的默认值 None 让“未提供”进入我们自定义的 401 判断,而不是先触发必填字段 422;本实验仅保护 /private。"
+      }
+    ],
+    "checks": [
+      "/docs 显示 /private 和 X-API-Key 请求头输入项。",
+      "配置环境变量后,缺失或错误密钥返回 401 和 http_error;正确密钥返回 200 和 {\"message\":\"API key accepted\"}。",
+      "未设置 DEMO_API_KEY 的新服务进程返回 503,而不会因为请求头与配置都为空而通过。",
+      "拆分文件后 /health、原有商品 CRUD 与错误结构仍然可用。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "已在客户端写 X-API-Key,却显示密钥无效",
+        "cause": "服务读取的头部来源声明不正确,或者客户端把值放到了查询字符串。",
+        "fix": "依赖明确使用 Header(alias='X-API-Key');在 Network 或 curl 输出中核对 Request Headers。"
+      },
+      {
+        "symptom": "环境变量已设置,接口仍返回 503",
+        "cause": "变量设置在另一个终端,或者旧服务进程没有重启。",
+        "fix": "在设置变量的同一个终端停止并重启 Uvicorn。"
+      },
+      {
+        "symptom": "新增 private.py 后 /private 返回 404",
+        "cause": "只创建了 router,没有在主 app 上 include_router,或启动时目录不正确。",
+        "fix": "确认 main.py 注册 private.router,并从 api-lab 根目录启动 main:app。"
+      }
+    ],
+    "exercise": {
+      "task": "在 routers/private.py 中新增 GET /private/status,返回 {\"enabled\":true}。复用 router 上的依赖,分别验证无头部与正确头部。再说明为什么这仍不能判断是哪位学生发出的请求。",
+      "hint": "只添加 @router.get('/private/status') 的函数,不必复制校验代码。",
+      "answer": "添加:\n@router.get('/private/status')\ndef private_status():\n    return {'enabled': True}\n\n无 X-API-Key 返回 401;classroom-key 返回 200 和 {'enabled':true}。两个接口自动复用 router 依赖。所有人使用同一公开密钥,服务只能判断是否匹配,无法识别具体用户。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:依赖注入",
+        "url": "https://fastapi.tiangolo.com/tutorial/dependencies/"
+      },
+      {
+        "label": "FastAPI:Header 参数",
+        "url": "https://fastapi.tiangolo.com/tutorial/header-params/"
+      },
+      {
+        "label": "FastAPI:多文件应用",
+        "url": "https://fastapi.tiangolo.com/tutorial/bigger-applications/"
+      },
+      {
+        "label": "Python:compare_digest",
+        "url": "https://docs.python.org/3/library/secrets.html#secrets.compare_digest"
+      }
+    ],
+    "intro": {
+      "scenario": "商品目录已经能操作了,现在另加一个课堂用的“内部入口”:只有带正确 X-API-Key 才能访问。顺便把它放进独立文件,看多个文件如何组成同一个服务。",
+      "startingPoint": [
+        "已完成 API 第 07 课,api-lab/main.py 的错误响应有统一 error 对象。",
+        "仍使用同一 .venv;本课新增 dependencies.py 和 routers/private.py,保留商品接口。",
+        "公开课堂值 classroom-key 仅用于本地练习,不把它当作真实用户认证系统。"
+      ],
+      "outcome": "在独立文件实现 /private,并看到缺少、错误、正确校验值分别被拒绝或放行。"
+    }
+  },
+  {
+    "id": "09",
+    "group": "异步与调用",
+    "title": "使用 async / await 处理 I/O 等待",
+    "summary": "同时发出两次各等待 1 秒的请求,比较两种写法的总耗时,并能把阻塞写法改回可交错等待的实现。",
+    "objectives": [
+      "建立有固定等待时间的本地接口",
+      "用实际请求比较约 1 秒与约 2 秒的差异",
+      "将无法替换的同步等待放到线程中,并恢复可继续学习的代码"
+    ],
+    "concepts": [
+      {
+        "title": "先看等待是否重叠,再理解 await",
+        "body": "第 2 步两个各等 1 秒的请求总计约 1 秒;第 3 步换成 time.sleep 后约 2 秒。await 的价值是把等待时间让给其他任务,async 本身不会自动改造阻塞函数。"
+      },
+      {
+        "title": "I/O 等待与计算分别处理",
+        "body": "本课 sleep 只是模拟网络或磁盘等 I/O 等待;计算密集任务即使写进 async 也不会凭空加快。第 4 步线程方案适用于无法替换的同步等待。"
+      },
+      {
+        "title": "执行位置决定会拖住谁",
+        "body": "async 路由由事件循环推进,普通 def 路由由 FastAPI 交到线程池。第 3 步是在 async 内直接调用阻塞函数,所以连 /health 都受影响;第 4 步显式 to_thread 才改变它的位置。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "增加一个可测量的等待接口",
+        "body": "在已激活的 api-lab 终端执行 python -m pip install \"httpx>=0.27,<1\"。把下方路由追加到 main.py 并保存;访问 /slow?delay=1,再试 /slow?delay=-1。服务只启动一个进程。",
+        "code": "import asyncio\nfrom fastapi import Query\n\n@app.get(\"/slow\")\nasync def slow(delay: float = Query(default=1, ge=0, le=5)):\n    await asyncio.sleep(delay)\n    return {\"delay\": delay}",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "第一次约等 1 秒后返回 {\"delay\":1},第二次立刻得到 422;/health 仍可访问。若安装提示找不到 Python 包,先核对 .venv 路径。",
+        "explanation": "asyncio.sleep 模拟等待外部结果,await 在等待时让其他任务有机会执行。Query 把 delay 限制为 0–5 秒,避免误输过大的等待时间;HTTPX 将在下一步充当同时发请求的客户端。"
+      },
+      {
+        "title": "同时发出两次请求",
+        "body": "在 api-lab/concurrent_check.py 保存下面代码。保持服务运行,在第二个终端执行 python concurrent_check.py,记下状态列表和 elapsed。页面交互是模拟,计时以这个脚本为准。",
+        "code": "import asyncio\nfrom time import perf_counter\nimport httpx\n\nasync def main():\n    async with httpx.AsyncClient(base_url=\"http://127.0.0.1:8000\") as client:\n        started = perf_counter()\n        results = await asyncio.gather(\n            client.get(\"/slow\", params={\"delay\": 1}),\n            client.get(\"/slow\", params={\"delay\": 1}),\n        )\n        print([r.status_code for r in results])\n        print(f\"elapsed={perf_counter() - started:.2f}s\")\n\nasyncio.run(main())",
+        "lang": "python",
+        "filename": "concurrent_check.py",
+        "expected": "输出 [200, 200],总耗时通常接近 1 秒加通信开销,而不是两次等待简单相加的 2 秒。连接失败时先检查服务是否在 8000 运行。",
+        "explanation": "asyncio.gather 让两个请求同时进行,AsyncClient 负责实际收发。服务在每次 await sleep 时可以处理另一条请求,因此两段等待重叠;async 并没有把单次 1 秒缩短。"
+      },
+      {
+        "title": "复现阻塞,再恢复",
+        "body": "在 main.py 临时加入 import time,将 /slow 中 await asyncio.sleep(delay) 换成 time.sleep(delay)。等待重载后重跑计时脚本,并在两次慢请求进行时从第三个终端访问 /health。记录后立刻恢复 await asyncio.sleep(delay)。macOS / Linux 把下面 curl.exe 改为 curl。",
+        "code": "curl.exe \"http://127.0.0.1:8000/health\"",
+        "lang": "bash",
+        "expected": "阻塞写法下两次请求通常约 2 秒,等待期间 /health 也可能迟迟不返回;恢复后计时通常又接近 1 秒。以明显差异判断,不要求毫秒完全一致。",
+        "explanation": "time.sleep 直接占住 async 路由所在的执行线程,函数名字前写 async 也不能自动把它变成可让出的等待。其他请求被拖住,正是事件循环无法继续处理它们的表现。"
+      },
+      {
+        "title": "为无法替换的同步 I/O 选择线程",
+        "body": "把下面 slow_sync 函数加到 main.py。在 /slow 函数内,把原来的等待和 return 两行临时改成 result = await asyncio.to_thread(slow_sync, delay) 与 return result(去掉示例前的 #)。重跑 concurrent_check.py;验收后把 /slow 恢复为第 1 步的 asyncio.sleep 版本供后课使用。",
+        "code": "import time\n\ndef slow_sync(delay: float):\n    time.sleep(delay)\n    return {\"delay\": delay}\n\n# 放在 async def 路由内:\n# result = await asyncio.to_thread(slow_sync, delay)\n# return result",
+        "lang": "python",
+        "expected": "使用 to_thread 后两次请求通常又能在约 1 秒完成,/health 不必等待两段串行 sleep。最后 /slow 保持异步 sleep 原版,下一课的超时实验有确定起点。",
+        "explanation": "to_thread 把同步辅助函数交到另一个线程执行,让当前异步路由可以等待而不占住事件循环。FastAPI 会在线程池运行普通 def 路由,却不会自动搬走 async def 里直接调用的普通函数;线程也不是本课大量 CPU 计算的加速方案。"
+      }
+    ],
+    "checks": [
+      "异步版本两个 delay=1 请求均返回 200,总耗时通常接近 1 秒。",
+      "阻塞版本在单进程下耗时明显增加,恢复后 /health 不再等待 slow 的 sleep 结束。",
+      "/slow?delay=-1 与 /slow?delay=6 都返回 422。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "写了 async def,其他请求仍卡住",
+        "cause": "路由内使用 time.sleep、同步 HTTP 客户端或长时间计算,占住事件循环。",
+        "fix": "定位耗时调用;I/O 改用 await 支持的 API,必要时把同步 I/O 放到线程。"
+      },
+      {
+        "symptom": "两次 await 请求仍耗时约 2 秒",
+        "cause": "先 await 第一次,再创建第二次,代码仍是串行等待。",
+        "fix": "用 asyncio.gather 同时调度两个请求,再比较结果。"
+      },
+      {
+        "symptom": "出现 coroutine was never awaited",
+        "cause": "调用异步函数但没有 await,也没有交给任务调度。",
+        "fix": "在 async 函数内 await 结果;脚本最外层用 asyncio.run(main())。"
+      }
+    ],
+    "exercise": {
+      "task": "把脚本改成同时请求 3 个 delay=1 的 /slow,再增加一次 /health,记录总耗时及各状态码。",
+      "hint": "把第三个 /slow 和 /health 一并放进 gather;不要依次 await。",
+      "answer": "四个请求应全部返回 200。使用非阻塞等待时,总耗时通常仍接近最长的一次等待,即约 1 秒;若接近 3 秒,先检查服务是否恢复了 asyncio.sleep。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:并发与 async / await",
+        "url": "https://fastapi.tiangolo.com/async/"
+      },
+      {
+        "label": "Python:协程、gather 与 to_thread",
+        "url": "https://docs.python.org/3/library/asyncio-task.html"
+      }
+    ],
+    "demo": "async",
+    "intro": {
+      "scenario": "假设商品目录需要等另一个服务返回结果。一件商品要等 1 秒,同时查两件一定要等 2 秒吗?先用可控的等待实验观察,再解释 async 和 await。",
+      "startingPoint": [
+        "已完成 API 第 08 课,api-lab/main.py 能启动且 /health 返回 200;继续同一 .venv。",
+        "保留一个 Uvicorn 进程,另开已激活环境的终端执行客户端脚本。本课新增 concurrent_check.py;安装命令在第 1 步给出。"
+      ],
+      "outcome": "同时发出两次各等待 1 秒的请求,比较两种写法的总耗时,并能把阻塞写法改回可交错等待的实现。"
+    }
+  },
+  {
+    "id": "10",
+    "group": "异步与调用",
+    "title": "使用 HTTPX 调用服务与调试 CORS",
+    "summary": "看到 API 等待过久时返回 504,并让本机商品页面在正确来源下创建商品、在不匹配来源下显示浏览器错误。",
+    "objectives": [
+      "让应用创建和关闭同一个 HTTP 客户端",
+      "对照正常调用和超时调用的响应",
+      "用两个网页地址复现跨来源访问差异并读出相关请求头"
+    ],
+    "concepts": [
+      {
+        "title": "一段是入站,一段是出站",
+        "body": "第 2 步 /upstream-demo 收到浏览器请求后再请求 /slow。观察 504 时先定位第二段等待,别把它与第 4 步浏览器来源错误混在一起。"
+      },
+      {
+        "title": "客户端随应用复用和关闭",
+        "body": "第 1 步在 lifespan 中创建 AsyncClient,后续请求复用连接,退出时关闭。不同超时参数限制不同阶段,read 通常是等待下一块数据的上限。"
+      },
+      {
+        "title": "浏览器以完整来源比较授权",
+        "body": "第 4 步同一网页换 localhost 后失败。Origin 包括协议、主机和端口;第 5 步的响应头提供判断依据,curl 返回了数据也不等于浏览器授权通过。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "把客户端交给 lifespan 管理",
+        "body": "在 main.py 中把下方 lifespan 定义放到 app 创建之前,将原来的 FastAPI(...) 初始化改为 FastAPI(lifespan=lifespan)。已有 lifespan 时合并客户端管理;保留全部路由并只创建一个 app。若未装 HTTPX,先在 .venv 执行 python -m pip install \"httpx>=0.27,<1\"。",
+        "code": "from contextlib import asynccontextmanager\nimport httpx\nfrom fastapi import FastAPI\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    timeout = httpx.Timeout(3.0, connect=1.0)\n    async with httpx.AsyncClient(\n        base_url=\"http://127.0.0.1:8000\", timeout=timeout\n    ) as client:\n        app.state.http = client\n        yield\n\napp = FastAPI(lifespan=lifespan)",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "重启后 /health 与 /slow 仍可用,没有第二个 app 把已注册路由覆盖;服务启动和停止都无客户端创建或关闭异常。",
+        "explanation": "lifespan 是应用启动到关闭的管理入口:yield 前创建客户端,yield 后退出上下文并关闭。app.state.http 保存这份可复用客户端。base_url 固定本机,connect 限制建立连接等待,其余阶段默认 3 秒,并非整个请求总时限。"
+      },
+      {
+        "title": "把出站失败转换为可判断的响应",
+        "body": "追加下方 /upstream-demo 路由。分别访问 /upstream-demo?delay=1 和 /upstream-demo?delay=4,记录等待时长及状态;保留 /slow 的异步 sleep,避免被上一课阻塞实验影响。",
+        "code": "from fastapi import HTTPException, Query, Request\n\n@app.get(\"/upstream-demo\")\nasync def upstream_demo(\n    request: Request,\n    delay: float = Query(default=1, ge=0, le=5),\n):\n    try:\n        response = await request.app.state.http.get(\n            \"/slow\", params={\"delay\": delay}\n        )\n        response.raise_for_status()\n        return {\"upstream\": response.json()}\n    except httpx.TimeoutException as exc:\n        raise HTTPException(504, \"上游响应超时\") from exc\n    except httpx.HTTPStatusError as exc:\n        raise HTTPException(502, \"上游返回非成功状态\") from exc\n    except httpx.RequestError as exc:\n        raise HTTPException(502, \"无法连接上游服务\") from exc",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "delay=1 返回 200 和 upstream.delay=1;delay=4 通常约 3 秒后返回 504,消息为上游响应超时。",
+        "explanation": "浏览器到本应用是一段请求,应用再到 /slow 是第二段;这里第二段模拟“上游服务”。HTTPX 的超时异常被转成 504,非成功状态与连接失败转成 502。raise_for_status 负责把非 2xx 变成可捕获错误;出站地址固定,调用方不能任意指定。"
+      },
+      {
+        "title": "在两个页面来源之间复现 CORS",
+        "body": "在 app 创建之后加入下方 CORSMiddleware;已有配置时修改它,不重复添加。暂时只保留 http://127.0.0.1:5500 这个允许来源;下载版若还允许 localhost:5500,先将后者移出。",
+        "code": "from fastapi.middleware.cors import CORSMiddleware\n\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"http://127.0.0.1:5500\"],\n    allow_credentials=False,\n    allow_methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\"],\n    allow_headers=[\"Content-Type\", \"X-API-Key\"],\n)",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "服务能正常重载;配置中明确只有一个允许来源。此时尚未打开前端页面,不会仅因加了配置就看到浏览器提示。",
+        "explanation": "这个配置将告诉浏览器哪些页面可以读取 API 响应。来源由协议、主机、端口共同组成,所以网页的 5500 与 API 的 8000 不同;localhost 和 127.0.0.1 也会被视为不同来源。"
+      },
+      {
+        "title": "建立前端页面并从 5500 访问 API",
+        "body": "在 api-lab/cors-demo.html 保存下方完整 HTML。另开终端进入 api-lab、激活 .venv,运行 python -m http.server 5500。打开 http://127.0.0.1:5500/cors-demo.html,按 F12 打开 Network,再点击按钮;然后通过 http://localhost:5500/cors-demo.html 打开同一文件并再次点击。",
+        "code": "<!doctype html>\n<html lang=\"zh-CN\">\n<meta charset=\"utf-8\">\n<title>CORS 实验</title>\n<button id=\"send\">向 API 创建商品</button>\n<pre id=\"result\"></pre>\n<script>\ndocument.querySelector(\"#send\").onclick = async () => {\n  const output = document.querySelector(\"#result\");\n  try {\n    const response = await fetch(\"http://127.0.0.1:8000/products\", {\n      method: \"POST\",\n      headers: {\"Content-Type\": \"application/json\"},\n      body: JSON.stringify({name: \"陶瓷杯\", price_cents: 1990}),\n    });\n    output.textContent = response.status + \"\\n\" + await response.text();\n  } catch (error) {\n    output.textContent = \"浏览器未能读取响应;检查 Network 和 Console:\" + error.message;\n  }\n};\n</script>\n</html>",
+        "lang": "html",
+        "filename": "cors-demo.html",
+        "expected": "第一个页面显示 201 和新商品字段,Network 中可见 OPTIONS 与 POST;第二个页面显示无法读取响应的提示,Console 或 Network 有来源相关错误。",
+        "explanation": "这个 JSON POST 会先触发 OPTIONS 预检,浏览器确认服务允许该来源和请求头后再发送业务请求。两个网页文件相同却结果不同,说明这里检查的是页面来源;不是陶瓷杯数据格式发生了变化。"
+      },
+      {
+        "title": "读预检请求和响应头",
+        "body": "在 Network 中展开 OPTIONS,比较 Origin 与 Access-Control-Allow-Origin。再用第二个终端执行下方 curl 预检请求,随后执行超时请求;macOS / Linux 用 curl。把 curl 的 Origin 改为 http://localhost:5500 对照后恢复。若以后需要两种网页地址,再把它们都加入允许列表。",
+        "code": "curl.exe -i -X OPTIONS \"http://127.0.0.1:8000/products\" -H \"Origin: http://127.0.0.1:5500\" -H \"Access-Control-Request-Method: POST\" -H \"Access-Control-Request-Headers: content-type\"\ncurl.exe -i \"http://127.0.0.1:8000/upstream-demo?delay=4\"",
+        "lang": "bash",
+        "expected": "允许来源的预检响应包含匹配的 Access-Control-Allow-Origin;不匹配来源不会得到同样的授权头。超时请求仍为 504,它与网页来源错误有不同触发路径。",
+        "explanation": "CORS 是浏览器决定页面能否读响应的机制;curl 和服务端 HTTPX 不执行这种页面限制。因此命令行能拿到响应并不能证明网页可以读取,CORS 也不能代替第 08 课那类身份检查。"
+      }
+    ],
+    "checks": [
+      "/upstream-demo?delay=1 返回 200,包含上游的 delay 字段。",
+      "/upstream-demo?delay=4 返回 504;把上游地址临时改为未监听端口可观察 502,随后恢复。",
+      "允许来源的预检响应包含 Access-Control-Allow-Origin: http://127.0.0.1:5500,浏览器可以读取响应。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "上游返回 404,接口却继续执行正常处理",
+        "cause": "HTTP 请求收到响应不等于业务成功,代码没有检查状态。",
+        "fix": "调用 raise_for_status 并单独处理 HTTPStatusError。"
+      },
+      {
+        "symptom": "curl 成功,网页报 CORS 错误",
+        "cause": "浏览器页面来源与 allow_origins 不匹配,或预检方法、请求头未被允许。",
+        "fix": "逐项核对 Origin、方法和自定义请求头,再看浏览器 Network 中的 OPTIONS 响应。"
+      },
+      {
+        "symptom": "用 timeout=3 就以为整个请求最多 3 秒",
+        "cause": "把阶段性超时误认为总请求截止时间,流式响应每次及时发送数据时可能持续更久。",
+        "fix": "先明确要限制连接、块间等待还是总耗时;本课实验只验证读超时。"
+      }
+    ],
+    "exercise": {
+      "task": "让 /upstream-demo 在等待超过 0.2 秒时返回 504,同时保持直接访问 /slow?delay=1 能正常返回。",
+      "hint": "只覆盖 client.get 的 timeout 参数,保留异步 /slow。",
+      "answer": "给该次请求设置 timeout=httpx.Timeout(0.2, connect=1.0)。请求 /upstream-demo?delay=1 会在等待上游数据时触发超时;直接请求 /slow?delay=1 仍约 1 秒后返回 200。"
+    },
+    "refs": [
+      {
+        "label": "HTTPX:异步客户端",
+        "url": "https://www.python-httpx.org/async/"
+      },
+      {
+        "label": "HTTPX:超时阶段",
+        "url": "https://www.python-httpx.org/advanced/timeouts/"
+      },
+      {
+        "label": "HTTPX:状态码与异常",
+        "url": "https://www.python-httpx.org/quickstart/"
+      },
+      {
+        "label": "FastAPI:lifespan",
+        "url": "https://fastapi.tiangolo.com/advanced/events/"
+      },
+      {
+        "label": "FastAPI:CORS",
+        "url": "https://fastapi.tiangolo.com/tutorial/cors/"
+      }
+    ],
+    "intro": {
+      "scenario": "商品页面向 API 发请求后,API 有时还要等待另一项服务。我们先让 API 请求本机的等待接口,再从一个简单商品网页调用 API,分别观察服务超时和浏览器不让页面读响应的现象。",
+      "startingPoint": [
+        "已完成 API 第 09 课并恢复 /slow 的 await asyncio.sleep 写法;HTTPX 已装进 api-lab/.venv。",
+        "已有第 06 课 POST /products 和第 07 课错误处理。先访问 /slow?delay=1 确认约 1 秒返回。",
+        "本课保留 API 在 8000,新增 cors-demo.html 并在第二终端启动 5500;实验服务均在本机。"
+      ],
+      "outcome": "看到 API 等待过久时返回 504,并让本机商品页面在正确来源下创建商品、在不匹配来源下显示浏览器错误。"
+    }
+  },
+  {
+    "id": "11",
+    "group": "流式与实时通信",
+    "title": "完整响应、流式响应、SSE 与 WebSocket",
+    "summary": "亲眼比较等完再显示、逐段显示和连接内继续发送消息,并为三个简单商品场景选择合适的通信方式。",
+    "objectives": [
+      "运行一次等待完成后返回的清单进度接口",
+      "在交互演示中比较第一段结果何时出现",
+      "用商品保存、清单进度与咨询消息说明选择理由"
+    ],
+    "concepts": [
+      {
+        "title": "完整响应先准备好结果",
+        "body": "第 2 步所有进度文字一起出现,因为函数在三段处理后才 return。第 12 课会把相同内容改成逐段发送,方便公平比较。"
+      },
+      {
+        "title": "SSE 是带事件边界的 HTTP 文本流",
+        "body": "第 3 步的逐条进度可以用 SSE 表达:响应类型 text/event-stream,空行分隔事件,方向是服务端向客户端推送。它仍然建立在 HTTP 响应上。"
+      },
+      {
+        "title": "WebSocket 适合连接内双向消息",
+        "body": "本页模拟里可以在连接保持时继续发咨询,双方不必每条消息都新建 HTTP 请求。消息类型、完成、错误与断线行为仍需要自己定义。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "先跑一次完整结果接口",
+        "body": "把下方 PromptIn 模型和 /generate 路由追加到 api-lab/main.py;模型放在使用它的函数之前。保存后刷新 /docs,找到 POST /generate。",
+        "code": "import asyncio\nfrom pydantic import BaseModel, Field\n\nclass PromptIn(BaseModel):\n    prompt: str = Field(min_length=1, max_length=200)\n\n@app.post(\"/generate\")\nasync def generate(payload: PromptIn):\n    parts = []\n    for text in [\"准备导出商品清单:\", payload.prompt, \"。导出进度演示完成。\"]:\n        await asyncio.sleep(0.4)\n        parts.append(text)\n    return {\"text\": \"\".join(parts)}",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "文档中出现 /generate,输入需要 prompt;此时没有生成下载文件或后台作业,这个接口只准备返回文字。",
+        "explanation": "PromptIn 限定一个非空清单名,循环模拟三次各 0.4 秒的处理。parts 先收集所有片段,最后 join 成一条完整文字再 return,所以响应内容要等循环结束才可用。"
+      },
+      {
+        "title": "记录完整响应的等待",
+        "body": "在 /docs 的 POST /generate 请求体框中输入下方 JSON,点击 Execute;从点击开始观察,记录业务文字何时出现,再重复一次。",
+        "code": "{\"prompt\":\"catalog\"}",
+        "lang": "json",
+        "expected": "大约 1.2 秒加通信开销后,一次看到完整的“准备导出商品清单:catalog。导出进度演示完成。”,中间没有逐条进度文字。",
+        "explanation": "这次等待来自三次 sleep,return 在循环之后执行。HTTP 响应头何时到达与可用业务文字何时到达不是同一件事;我们关心的是用户何时开始看到内容。"
+      },
+      {
+        "title": "对照三条连接过程",
+        "body": "操作本页协议演示,依次选择完整 HTTP、SSE、WebSocket。每次先开始再观察第一段文字的出现时机;在 WebSocket 模式尝试保持连接并继续发一条商品咨询。此处不需要把演示动作复制到终端。",
+        "expected": "完整响应等完成后显示文字,SSE 模式逐条显示片段,WebSocket 模式允许在同一连接中再发消息。这些是浏览器模拟的现象,真实实现从下一课开始。",
+        "explanation": "流式 HTTP 把同一个响应体分段送达;SSE 在文本流里规定了“每条事件在哪里结束”。WebSocket 则让双方在连接建立后独立发送消息,这解释了它适合来回咨询。"
+      },
+      {
+        "title": "把场景写成协议选择题",
+        "body": "在学习笔记写下三个具体选择:保存陶瓷杯字段、等待商品清单进度、在连接中反复咨询商品。为每项补一句“用户需要什么时候看到什么”,再与预期对照。",
+        "expected": "可以得到:保存商品用一次 POST/响应;连续展示进度可用 SSE;双方持续发消息可用 WebSocket。只偶尔查进度时,定期 GET 也可满足需求。",
+        "explanation": "通信方式由交互需要决定:是否需要早到片段、谁能主动发送、连接是否持续。REST 讨论资源和 HTTP 操作,不禁止流式返回;用 WebSocket 也不会自动得到消息保存或真实客服逻辑。"
+      }
+    ],
+    "checks": [
+      "能说清 SSE 使用 HTTP,且不是 WebSocket 的别名。",
+      "/generate 返回单个 {text:...} 对象;不会在每个步骤结束时返回一个新 HTTP 响应。",
+      "能为保存商品、逐步显示文本、双向实时消息分别选择方案并说明原因。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "为了逐字显示文字,直接把所有接口都改成 WebSocket",
+        "cause": "把流式接收和双向消息通信混为一谈。",
+        "fix": "先确定客户端是否需要在同一条连接上持续主动发送;单向文本结果通常先考虑 HTTP 流式响应。"
+      },
+      {
+        "symptom": "把服务器 yield 的次数当成客户端 read 的次数",
+        "cause": "网络、服务器和浏览器可能合并或拆开数据块。",
+        "fix": "依赖协议的事件分隔符解析消息,不依赖网络块边界。"
+      }
+    ],
+    "exercise": {
+      "task": "用户提交一次短文本后只接收生成结果,偶尔点击“停止”。是否必须使用 WebSocket?",
+      "hint": "思考取消 HTTP 请求是否已经满足停止读取的需要。",
+      "answer": "不必须。可以提交 HTTP 请求并读取流式响应,通过取消请求停止读取;服务端同时处理断开并释放资源。如果还需要在同一会话持续双向发送独立消息,再评估 WebSocket。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:StreamingResponse",
+        "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
+      },
+      {
+        "label": "MDN:SSE 事件流",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
+      },
+      {
+        "label": "FastAPI:WebSocket",
+        "url": "https://fastapi.tiangolo.com/advanced/websockets/"
+      }
+    ],
+    "demo": "protocol",
+    "intro": {
+      "scenario": "商品清单导出需要一点时间:页面可以等全部完成,也可以边等边显示进度。再换成商品咨询,用户与服务还需要不断往返消息。先用一个小实验比较这几种体验。",
+      "startingPoint": [
+        "已完成 API 第 10 课,main.py 可正常启动,已会在 /docs 发送 JSON;保留 asyncio 和 Pydantic 依赖。",
+        "本课先新增 /generate,仍在 api-lab 中操作。进度只模拟三个片段,不生成真实导出文件。",
+        "本页协议交互用于观察;后面第 12–15 课会逐个实现真实连接,不必先掌握所有协议名。"
+      ],
+      "outcome": "亲眼比较等完再显示、逐段显示和连接内继续发送消息,并为三个简单商品场景选择合适的通信方式。"
+    }
+  },
+  {
+    "id": "12",
+    "group": "流式与实时通信",
+    "title": "用 StreamingResponse 输出 SSE 事件",
+    "summary": "在终端依次收到三条进度和一条完成事件,并在主动断开后看到服务端清理日志。",
+    "objectives": [
+      "把一条进度编码成明确的事件格式",
+      "让服务每次产生一个片段并最终发送 done",
+      "用真实网络观察逐段到达和中途取消"
+    ],
+    "concepts": [
+      {
+        "title": "yield 让结果逐段交出去",
+        "body": "第 4 步文字每隔约 0.4 秒出现,来自生成器每轮 yield;StreamingResponse 负责发送。与上一课最后 return 相比,业务内容更早可见。"
+      },
+      {
+        "title": "双方依赖同一事件约定",
+        "body": "第 5 步核对了 token 的 {text:...}、done 的 {} 和空行边界。JSON 编码保护数据内容,固定事件名保护协议结构,不能把任意输入直接拼成协议行。"
+      },
+      {
+        "title": "开始前校验,结束时清理",
+        "body": "第 3 步先校验 prompt,第 4 步取消后 finally 打印清理。响应头发送后不能改写 HTTP 状态,后续异常要约定流内错误或明确的连接结束行为。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "定义一处统一的事件编码",
+        "body": "在 main.py 的辅助函数区添加 encode_sse。为方便先看到格式,可在第二终端执行 python 进入提示符,输入 from main import encode_sse,再输入 print(encode_sse(\"token\", {\"text\":\"陶瓷杯\"})),之后 exit()。",
+        "code": "import json\n\ndef encode_sse(event: str, data: dict) -> str:\n    payload = json.dumps(data, ensure_ascii=False)\n    return f\"event: {event}\\ndata: {payload}\\n\\n\"",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "打印中有 event: token、data: {\"text\": \"陶瓷杯\"},数据后还留出空行。JSON 使用双引号,中文可直接读。",
+        "explanation": "SSE 用 event 指定事件名、data 保存内容,两个换行形成事件结束的空行。json.dumps 负责转义内容里的引号和换行,避免用户文本被误解成另一行协议;event 名由代码固定。"
+      },
+      {
+        "title": "产生有限长度 token 流",
+        "body": "继续在 main.py 添加 token_events。保留循环中的等待、断开检查、三个 token 和最后的 done;它暂时还没有接到 HTTP 地址。",
+        "code": "import asyncio\nfrom fastapi import Request\n\nasync def token_events(request: Request, prompt: str):\n    try:\n        for text in [\"准备导出商品清单:\", prompt, \"。导出进度演示完成。\"]:\n            await asyncio.sleep(0.4)\n            if await request.is_disconnected():\n                return\n            yield encode_sse(\"token\", {\"text\": text})\n        yield encode_sse(\"done\", {})\n    finally:\n        print(\"stream closed\")",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "文件可重载;函数明确会产生三个文字事件和一个 done,并有 finally 中的 stream closed。仅定义函数不会立即输出事件。",
+        "explanation": "yield 每次交出一段,而不是像上一课那样积累后一次 return。生成器可以在两段之间 await;request.is_disconnected 检查客户端是否离开,finally 则让正常完成、断开或取消都经过清理路径。"
+      },
+      {
+        "title": "在 /stream 返回 StreamingResponse",
+        "body": "在已有 app 上添加下方 GET /stream,已有同名路由则替换,避免重复注册。保存后在 /docs 查看 prompt 的默认值与长度要求,先保留默认 catalog。",
+        "code": "from fastapi import Query\nfrom fastapi.responses import StreamingResponse\n\n@app.get(\"/stream\")\nasync def stream(\n    request: Request,\n    prompt: str = Query(default=\"catalog\", min_length=1, max_length=200),\n):\n    return StreamingResponse(\n        token_events(request, prompt),\n        media_type=\"text/event-stream\",\n        headers={\"Cache-Control\": \"no-cache\"},\n    )",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "/docs 中出现 GET /stream;空 prompt 会在请求开始前被拒绝,正常请求使用 text/event-stream 作为响应类型。下一步从终端观察逐段结果。",
+        "explanation": "StreamingResponse 持续读取上一步生成器并把片段发送出去。prompt 先完成校验,再开始流;响应头一旦发送,就不能事后把已经开始的 200 改成 500,因此错误边界要先想清。"
+      },
+      {
+        "title": "从终端逐段观察,再主动取消",
+        "body": "在第二个终端执行下方命令;macOS / Linux 将 curl.exe 改为 curl。完整看一次后再请求一次,在第一个片段出现时按 Ctrl+C,同时看 Uvicorn 终端。",
+        "code": "curl.exe -N -i \"http://127.0.0.1:8000/stream?prompt=catalog\"",
+        "lang": "bash",
+        "expected": "完整请求每隔约 0.4 秒出现一条 token,最后出现 done;正常完成和中途取消后服务端最终都有 stream closed。第二次可能来不及收到全部片段。",
+        "explanation": "-N 关闭 curl 自己的输出缓冲,让已到达的数据尽快显示。网络断开可能由检查或取消路径被观察到;finally 负责释放本连接相关资源,真实文件或上游迭代器也应在这类路径显式关闭。"
+      },
+      {
+        "title": "核对线上实际字节格式",
+        "body": "把刚才终端输出与下方事件体展示逐行对照,尤其检查每个 data 后的空行。此代码块是网络格式示意,不是需要运行的命令。",
+        "code": "event: token\ndata: {\"text\": \"准备导出商品清单:\"}\n\nevent: token\ndata: {\"text\": \"catalog\"}\n\nevent: token\ndata: {\"text\": \"。导出进度演示完成。\"}\n\nevent: done\ndata: {}\n\n",
+        "lang": "http",
+        "expected": "看到三条 event: token,最后为 event: done 且 data 是 {};每条事件之间都有空行,不会将相邻 JSON 黏在一起。",
+        "explanation": "token 只是本课程为文字片段选择的事件名,不是模型分词。JSON 字符串内部的换行会变成转义序列,协议边界仍由外层空行决定;正常完成靠 done 明确表达,不能仅凭连接消失判断。"
+      }
+    ],
+    "checks": [
+      "响应 Content-Type 以 text/event-stream 开头;终端能在流结束前看到 token。",
+      "正常结束只有一个 done,三个 token 的 text 拼接后与 /generate 的 text 相同。",
+      "prompt 为空返回 422;请求中途取消后清理日志出现,服务仍能处理 /health。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "客户端一直等不到事件回调",
+        "cause": "发送了 data 行却漏了事件末尾的空行。",
+        "fix": "每条事件统一通过 encode_sse 编码,并确认末尾为 \\n\\n。"
+      },
+      {
+        "symptom": "输入含换行后事件解析失败",
+        "cause": "手工拼接 JSON 或把原始输入直接放进 data 行。",
+        "fix": "先 json.dumps,再写入 SSE data 字段。"
+      },
+      {
+        "symptom": "断开后仍持续执行,或者没有清理资源",
+        "cause": "无限循环未处理断开、没有 await 让取消生效,或遗漏 finally 清理。",
+        "fix": "在迭代中检查断开,使用可等待的 I/O,并在 finally 中释放资源;保持本课生成器有限长度。"
+      }
+    ],
+    "exercise": {
+      "task": "在三个 token 后、done 前增加一个 stats 事件,返回 token_count:3;保持已有客户端仍能完成。",
+      "hint": "stats 是独立事件类型,不需要把它伪装成 token。",
+      "answer": "在 done 前增加 yield encode_sse(\"stats\", {\"token_count\": 3})。原客户端只监听 token 和 done 时会忽略 stats;新增监听器后可以显示统计值。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:自定义与流式响应",
+        "url": "https://fastapi.tiangolo.com/advanced/custom-response/"
+      },
+      {
+        "label": "MDN:SSE 消息格式",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"
+      },
+      {
+        "label": "FastAPI:Request 类与断开检查",
+        "url": "https://fastapi.tiangolo.com/reference/request/"
+      }
+    ],
+    "intro": {
+      "scenario": "上一课清单进度要等全部结束才出现。现在保留同样三个片段,让终端边等边看到它们,再在中途停止一次,检查服务是否清理连接。",
+      "startingPoint": [
+        "已完成 API 第 11 课,main.py 中 POST /generate 能返回完整清单文字,服务在 8000。",
+        "继续 api-lab/.venv;另开终端准备使用 curl(Windows 为 curl.exe)。本课新增 GET /stream,不替换 /generate。"
+      ],
+      "outcome": "在终端依次收到三条进度和一条完成事件,并在主动断开后看到服务端清理日志。"
+    }
+  },
+  {
+    "id": "13",
+    "group": "流式与实时通信",
+    "title": "在浏览器中读取流,并在中途停止",
+    "summary": "在浏览器逐条读出清单进度,能中途取消,也能把清单名放进 POST 请求体后继续接收同样的流。",
+    "objectives": [
+      "用 EventSource 收到 token 并在 done 后关闭",
+      "用 fetch 分块读取并按事件边界还原内容",
+      "验证主动停止和 POST 输入两种变化"
+    ],
+    "concepts": [
+      {
+        "title": "现成接收器适合简单 GET",
+        "body": "第 1 步 EventSource 自动处理 SSE,但我们仍监听命名事件并在 done 后 close。它不能任意配置请求体或自定义头,也不能把自动重连当成可靠业务恢复。"
+      },
+      {
+        "title": "网络块与业务事件不是一回事",
+        "body": "第 2 步连续解码字节、缓存文字、按空行拆事件;一次 read 可包含多个事件,也可能只含半个字符。这个解析器覆盖本课 LF/CRLF 格式,不实现完整 EventSource 的 id、retry 等行为。"
+      },
+      {
+        "title": "主动取消与结果不完整分开判断",
+        "body": "第 3 步 AbortError 明确表示用户停止;没有 done 就意外断开则报告错误。第 4 步 POST 只改变输入方式,仍可读取相同的流式响应。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "先用 EventSource 接收命名事件",
+        "body": "在本地 /docs 的 Console 执行下面完整代码,观察一次完成过程。再次执行后立刻输入 stopSse(),比较中途关闭的输出;若 Console 禁止直接粘贴,按浏览器提示检查代码后使用其允许的输入方式。",
+        "code": "{\n  const source = new EventSource(\"/stream?prompt=catalog\");\n  source.addEventListener(\"token\", (event) => {\n    console.log(JSON.parse(event.data).text);\n  });\n  source.addEventListener(\"done\", () => {\n    console.log(\"完成\");\n    source.close();\n  });\n  source.onerror = () => {\n    console.error(\"流连接失败,已停止\");\n    source.close();\n  };\n  window.stopSse = () => source.close();\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "正常时逐条打印三段文字和“完成”;主动 stopSse() 时后续片段停止。Network 中不会因 done 后自动反复新建同一请求。",
+        "explanation": "EventSource 已经会按 SSE 格式拆事件,但 token 是命名事件,需要 addEventListener(\"token\",...)。它默认具备重连行为,所以有限进度在 done 或本课错误处理时主动 close,避免重新执行。"
+      },
+      {
+        "title": "把事件解析与网络读取分开",
+        "body": "在同一 Console 定义下方 consumeSse 函数,完整复制到最后一个大括号。这一步只定义函数,先不再发请求;沿代码找到 reader.read、decoder、buffer 和 done 四个位置。",
+        "code": "async function consumeSse(response, onEvent) {\n  if (!response.ok) throw new Error(`HTTP ${response.status}`);\n  if (!response.headers.get(\"content-type\")?.includes(\"text/event-stream\")) {\n    throw new Error(\"响应不是 SSE\");\n  }\n  if (!response.body) throw new Error(\"没有响应体\");\n  const reader = response.body.getReader();\n  const decoder = new TextDecoder(\"utf-8\");\n  let buffer = \"\";\n  let completed = false;\n  try {\n    while (!completed) {\n      const { value, done } = await reader.read();\n      buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n      let boundary;\n      while ((boundary = /\\r?\\n\\r?\\n/.exec(buffer)) !== null) {\n        const block = buffer.slice(0, boundary.index);\n        buffer = buffer.slice(boundary.index + boundary[0].length);\n        const lines = block.split(/\\r?\\n/);\n        const name = lines.find(line => line.startsWith(\"event:\"))\n          ?.slice(6).trim() || \"message\";\n        const data = lines.filter(line => line.startsWith(\"data:\"))\n          .map(line => line.slice(5).replace(/^ /, \"\")).join(\"\\n\");\n        if (data) onEvent(name, JSON.parse(data));\n        if (name === \"done\") { completed = true; break; }\n      }\n      if (done && !completed) throw new Error(\"连接结束,但没有收到 done\");\n    }\n    await reader.cancel();\n  } catch (error) {\n    await reader.cancel().catch(() => {});\n    throw error;\n  } finally {\n    reader.releaseLock();\n  }\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "控制台可完成定义,可能显示 undefined,且没有新的 /stream 请求,这是预期的准备步骤。",
+        "explanation": "reader.read 得到的是网络字节块,不保证对应一个中文字或一条事件。TextDecoder(stream:true) 保存不完整 UTF-8 字节,buffer 保存跨块文本,遇到空行才提取事件;没有收到 done 就断开会报告不完整结果。"
+      },
+      {
+        "title": "用 fetch 读取同一个 GET 接口",
+        "body": "继续在 Console 定义并执行下方 startStream。完整完成一次后再次调用 startStream(),马上调用 stopStream();每次开始都使用函数内部新建的控制器。",
+        "code": "async function startStream() {\n  const controller = new AbortController();\n  window.stopStream = () => controller.abort();\n  try {\n    const response = await fetch(\"/stream?prompt=catalog\", {\n      signal: controller.signal,\n    });\n    await consumeSse(response, (name, data) => {\n      if (name === \"token\") console.log(data.text);\n      if (name === \"done\") console.log(\"完成\");\n    });\n  } catch (error) {\n    if (error.name === \"AbortError\") console.log(\"已取消\");\n    else console.error(error);\n  }\n}\nstartStream();",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "完整过程打印三个片段和“完成”;取消时打印“已取消”,不会显示成一般服务故障。服务端也会经过上课的 stream closed 清理路径。",
+        "explanation": "fetch 先拿到响应,再由 consumeSse 逐块读取。AbortController 的 signal 把停止动作传入本次请求,abort 触发的 AbortError 代表用户取消;读完后取消读取并释放 reader 的锁,避免悬挂资源。"
+      },
+      {
+        "title": "扩展:需要 POST 请求体时",
+        "body": "把下方 POST /stream 加入 main.py,保留已有 GET /stream。服务重载后在 Console 重新定义 startStream:将 fetch 地址改为 /stream,并增加 method:\"POST\"、headers:{\"Content-Type\":\"application/json\"}、body:JSON.stringify({prompt:\"catalog\"}),保留 signal。执行 startStream()。",
+        "code": "@app.post(\"/stream\")\nasync def stream_post(request: Request, payload: PromptIn):\n    return StreamingResponse(\n        token_events(request, payload.prompt),\n        media_type=\"text/event-stream\",\n        headers={\"Cache-Control\": \"no-cache\"},\n    )",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "Network 中此请求方法是 POST、Payload 有 prompt,控制台仍逐条收到相同的 token 和 done;不是一次性完整 JSON。",
+        "explanation": "请求如何发送与响应如何流式返回是两件事。原生 EventSource 不能任意附加 POST 请求体,而 fetch 可以;/generate 返回完整 JSON,不能误交给要求 text/event-stream 的 consumeSse。"
+      },
+      {
+        "title": "核对流为什么没有逐段显示",
+        "body": "再次用 curl -N(Windows curl.exe -N)请求 http://127.0.0.1:8000/stream?prompt=catalog,再运行浏览器 startStream()。比较第一段到达时机;若浏览器只最后一次显示,检查是否把消费代码换成了 response.text() 或 response.json()。",
+        "expected": "直连终端与浏览器都应逐段出现内容;主动停止后没有继续输出。若两边行为不同,记录是终端、读取代码还是经过中间服务时才出现差异。",
+        "explanation": "text() 和 json() 会等完整响应读完,失去边读边显示的效果。若本机两边都正常而通过中间服务才被积攒,才进一步检查代理缓冲;本课的分层对比先确定问题发生的位置。"
+      }
+    ],
+    "checks": [
+      "EventSource 收到三次 token 和一次 done 后关闭,不会周期性重新发起 /stream。",
+      "fetch 版本能正常显示中文;主动 stopStream 后输出“已取消”。",
+      "扩展 POST /stream 返回 SSE;POST /generate 仍返回 application/json。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "偶发 JSON.parse 错误,或中文出现替换字符",
+        "cause": "直接对每次 read 的内容解码和解析,忽略了字符、事件可能跨块。",
+        "fix": "保持同一 TextDecoder 并设置 stream:true,再缓存到完整空行边界后解析。"
+      },
+      {
+        "symptom": "一次成功结束后又出现同样文本",
+        "cause": "有限 SSE 结束后 EventSource 自动重连,客户端没有处理 done。",
+        "fix": "监听 done 调用 close;真实恢复能力需要单独定义,不默认重复执行。"
+      },
+      {
+        "symptom": "点击停止后仍把状态改成失败",
+        "cause": "把所有 fetch 异常都当成网络故障。",
+        "fix": "单独识别 AbortError,并同步把页面状态改为已取消。"
+      }
+    ],
+    "exercise": {
+      "task": "把一条包含“你好”的 SSE 消息拆成逐个字节的 ReadableStream,验证解析函数仍得到完整中文和 done。",
+      "hint": "用 TextEncoder 生成字节,再通过 Response 包装本地 ReadableStream;这只是解析器实验,不是 API 网络测试。",
+      "answer": "构造文本 'event: token\\ndata: {\"text\":\"你好\"}\\n\\nevent: done\\ndata: {}\\n\\n';对 TextEncoder().encode(text) 的每个字节调用 controller.enqueue(new Uint8Array([byte])),最后 close。用 new Response(stream,{headers:{\"Content-Type\":\"text/event-stream\"}}) 传入 consumeSse,回调应只收到完整 token 和 done。"
+    },
+    "refs": [
+      {
+        "label": "MDN:EventSource",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/EventSource"
+      },
+      {
+        "label": "MDN:读取 ReadableStream",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams"
+      },
+      {
+        "label": "MDN:TextDecoder.decode",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode"
+      },
+      {
+        "label": "MDN:AbortController",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/AbortController"
+      }
+    ],
+    "intro": {
+      "scenario": "终端已经能看到逐段进度,接下来把它显示到浏览器,并给用户一个“停止等待”的动作。先用浏览器现成接收器,再拆开看看手动读取为何不能每个网络块都当作完整事件。",
+      "startingPoint": [
+        "已完成 API 第 12 课,curl -N 直连 GET /stream 能收到三个 token 和 done。",
+        "保留第 11 课 PromptIn 与第 12 课 token_events、StreamingResponse 导入;本课仍编辑 api-lab/main.py。",
+        "在浏览器打开 http://127.0.0.1:8000/docs,按 F12 进入 Console;以下 JavaScript 均在这个页面执行,先避免跨来源干扰。"
+      ],
+      "outcome": "在浏览器逐条读出清单进度,能中途取消,也能把清单名放进 POST 请求体后继续接收同样的流。"
+    }
+  },
+  {
+    "id": "14",
+    "group": "流式与实时通信",
+    "title": "建立第一条 WebSocket 双向连接",
+    "summary": "在浏览器建立一条连接,连续发送咨询与 ping,收到 echo 与 pong,然后关闭并重新连接。",
+    "objectives": [
+      "在服务端接受连接并循环收发消息",
+      "等待浏览器连接打开后再发送两种文本",
+      "主动关闭并核对浏览器与服务端的结束日志"
+    ],
+    "concepts": [
+      {
+        "title": "先建立连接,再发送消息",
+        "body": "第 2 步等 open,第 3 步再 send;WebSocket 不使用 /docs 的 HTTP Execute。连接建立后可以连续收发多条消息。"
+      },
+      {
+        "title": "消息格式由双方约定",
+        "body": "本课发咨询文本收到 echo,发 ping 收到 pong。send_json 真正发送消息,return 字典不能代替 WebSocket 的发送动作。"
+      },
+      {
+        "title": "断开是要处理的正常出口",
+        "body": "第 4 步两端都看到关闭日志。WebSocketDisconnect 与 finally 让服务清理该连接资源,新建连接则重新开始一个生命周期。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "在 main.py 插入 /ws 路由",
+        "body": "把下方导入和 /ws 路由追加到 api-lab/main.py,保存并等待重载。保留 receive_text、send_json 与 finally 日志;不要把 send_json 改为 HTTP 路由式的 return 字典。",
+        "code": "from fastapi import WebSocket, WebSocketDisconnect\n\n@app.websocket(\"/ws\")\nasync def websocket_echo(websocket: WebSocket):\n    await websocket.accept()\n    try:\n        while True:\n            text = await websocket.receive_text()\n            if text == \"ping\":\n                await websocket.send_json({\"type\": \"pong\"})\n            else:\n                await websocket.send_json({\"type\": \"echo\", \"text\": text})\n    except WebSocketDisconnect:\n        pass\n    finally:\n        print(\"websocket closed\")",
+        "lang": "python",
+        "filename": "main.py",
+        "expected": "服务可正常启动;/ws 不会像普通 HTTP 操作那样出现 /docs 的 Execute 按钮,需从下一步浏览器代码连接。",
+        "explanation": "websocket.accept 接受连接,while True 等下一条文本,send_json 沿同一连接回发消息。收到 ping 时回 pong,其余文字回 echo;断开会抛出 WebSocketDisconnect,再进入 finally 清理。"
+      },
+      {
+        "title": "从浏览器建立连接",
+        "body": "在 http://127.0.0.1:8000/docs 的 Console 执行下面代码。打开 Network 的 WS 筛选,先等待“连接已打开”再继续下一步。",
+        "code": "{\n  const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n  window.echoSocket = socket;\n  socket.onopen = () => console.log(\"连接已打开\");\n  socket.onmessage = event => console.log(\"收到\", JSON.parse(event.data));\n  socket.onerror = () => console.error(\"连接发生错误\");\n  socket.onclose = event => console.log(\"已关闭\", event.code, event.reason);\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "控制台出现“连接已打开”,Network 中有 /ws 连接;不会立即出现 echo,因为我们还没发业务消息。",
+        "explanation": "ws:// 表示 WebSocket 地址,浏览器从 CONNECTING 走到 OPEN 后才可发数据。open、message、close 分别表示连接可用、收到消息、连接结束,不是同一个阶段的不同文字。"
+      },
+      {
+        "title": "在 OPEN 状态发送两种消息",
+        "body": "在同一 Console 执行下面代码。查看控制台和 Network 中 /ws 的消息列表,分清发出方向与收到方向。",
+        "code": "if (echoSocket.readyState === WebSocket.OPEN) {\n  echoSocket.send(\"这款陶瓷杯有多大?\");\n  echoSocket.send(\"ping\");\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "连续收到 {type:\"echo\",text:\"这款陶瓷杯有多大?\"} 与 {type:\"pong\"};两次往返使用同一条 /ws 连接。",
+        "explanation": "客户端发的是文本,服务端回的是 JSON,所以 onmessage 需要 JSON.parse。readyState 检查确保连接可用;send 只将内容送入发送流程,实际收到 echo 才是对方已处理的证据。"
+      },
+      {
+        "title": "主动关闭并确认生命周期结束",
+        "body": "执行下方 close,再观察浏览器与服务终端。随后重新执行第 2 步建立新连接,等 open 后再次执行第 3 步;验收完关闭新连接。",
+        "code": "echoSocket.close(1000, \"lesson finished\");",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "浏览器打印已关闭及 1000,服务端打印 websocket closed;新建连接后可再次收发。旧的 echoSocket 一旦关闭便不能拿来继续发送。",
+        "explanation": "1000 表示正常关闭。关页、断网或主动关闭都属于连接生命周期,服务要处理这些常见出口。重连得到新连接,并不自动恢复旧连接的聊天内容或执行状态。"
+      }
+    ],
+    "checks": [
+      "浏览器先收到 open,发送商品咨询后收到 {type:'echo',text:'这款陶瓷杯有多大?'}。",
+      "发送 ping 后收到 {type:'pong'};同一连接可重复收发。",
+      "主动关闭后收到 close,服务端清理日志出现,重新连接仍正常。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "调用 send 报连接尚未建立",
+        "cause": "new WebSocket 返回时握手仍在进行。",
+        "fix": "在 open 回调中发送,或先检查 readyState === WebSocket.OPEN。"
+      },
+      {
+        "symptom": "浏览器无任何回复,路由却 return 了字典",
+        "cause": "WebSocket 消息不会像 HTTP 响应一样自动序列化返回。",
+        "fix": "在连接内 await websocket.send_json(...)。"
+      },
+      {
+        "symptom": "把 WebSocket 地址粘到普通地址栏后得到 404",
+        "cause": "使用 HTTP GET 测试了 WebSocket 端点,没有进行 WebSocket 握手。",
+        "fix": "通过浏览器 WebSocket API 或支持 WS 的客户端测试,使用 ws:// 地址。"
+      }
+    ],
+    "exercise": {
+      "task": "增加一个文本命令 upper:hello,让服务端回复 {type:'upper',text:'HELLO'},其他消息保持回显。",
+      "hint": "把 startswith('upper:') 分支放在默认 echo 分支之前。",
+      "answer": "在 echo 的 else 之前加入 elif text.startswith(\"upper:\"): await websocket.send_json({\"type\":\"upper\",\"text\":text.removeprefix(\"upper:\").upper()})。浏览器收到的 JSON type 应为 upper。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:WebSocket 收发与断开",
+        "url": "https://fastapi.tiangolo.com/advanced/websockets/"
+      },
+      {
+        "label": "MDN:编写 WebSocket 客户端",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
+      }
+    ],
+    "intro": {
+      "scenario": "清单进度只需服务往浏览器推送;商品咨询则希望用户随时继续提问。现在保持一条连接,发送两种消息并观察回信,再主动关闭它。",
+      "startingPoint": [
+        "已完成 API 第 13 课,api-lab/main.py 服务在 8000,Uvicorn standard 依赖已安装。",
+        "在本地 /docs 打开浏览器 Console 与 Network,先让已有 SSE 完成或停止,便于观察新连接。",
+        "本课新增 /ws,只回显咨询文本,不做客服逻辑、身份认证或聊天保存。"
+      ],
+      "outcome": "在浏览器建立一条连接,连续发送咨询与 ping,收到 echo 与 pong,然后关闭并重新连接。"
+    }
+  },
+  {
+    "id": "15",
+    "group": "流式与实时通信",
+    "title": "连接断开后:重连、心跳与取消",
+    "summary": "让断线后重试间隔逐渐变长,服务恢复后能重新收发,并在手动停止后取消所有后续连接尝试。",
+    "objectives": [
+      "用 ping/pong 检查连接是否仍能往返",
+      "在断开出口安排有上限的重试",
+      "执行正常、断线恢复、主动停止三组实验"
+    ],
+    "concepts": [
+      {
+        "title": "重试必须有等待、上限和停止出口",
+        "body": "第 5 步观察了递增间隔、5 次上限和 stopWs 后无新请求。重试集中在 close 路径,避免 error 与 close 重复创建连接。"
+      },
+      {
+        "title": "心跳证明近期消息仍能往返",
+        "body": "第 4 步 Network 中的 ping/pong 是应用层普通消息;浏览器 API 不提供主动发送协议级 Ping 帧的方法。后台页面可能节流计时器,阈值要按使用场景调整。"
+      },
+      {
+        "title": "恢复连接不代表恢复业务",
+        "body": "第 5 步断线时未发送的文本不会自动补发。重连产生新 socket;可靠恢复需要单独的消息确认、编号和去重设计。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "先定义连接状态与定时器清理",
+        "body": "先执行 echoSocket.close() 关闭上一课连接,或刷新本地 /docs 页面。从 Console 执行下方状态变量与清理函数;本课各代码块按顺序执行,暂时不会建立新连接。",
+        "code": "let wsSocket = null;\nlet retryTimer = null;\nlet heartbeatTimer = null;\nlet pongTimer = null;\nlet wsStopped = true;\nlet retryCount = 0;\n\nfunction clearHeartbeat() {\n  clearInterval(heartbeatTimer);\n  clearTimeout(pongTimer);\n  heartbeatTimer = null;\n  pongTimer = null;\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "控制台没有新 WS 请求;变量初始为停止状态,重复整块声明若报已定义,应刷新页面后重来。",
+        "explanation": "这些变量分别记录当前连接、计划中的重试、定时心跳与等待 pong 的计时器。集中保存它们,停止时才能逐一取消,而不是留下页面已经不需要的任务。"
+      },
+      {
+        "title": "加入应用层心跳",
+        "body": "继续在同一 Console 定义 startHeartbeat。先只定义,不手工调用;下一步会在连接打开时启动它,在收到 pong 时取消等待计时。",
+        "code": "function startHeartbeat(socket) {\n  clearHeartbeat();\n  heartbeatTimer = setInterval(() => {\n    if (socket.readyState !== WebSocket.OPEN) return;\n    socket.send(\"ping\");\n    pongTimer = setTimeout(() => {\n      socket.close(4000, \"heartbeat timeout\");\n    }, 3000);\n  }, 5000);\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "函数定义完成但 Network 里仍没有周期 ping;心跳要等实际连接后才开始。",
+        "explanation": "每 5 秒发一次普通文本 ping,3 秒没有回复就关闭该连接。这里的间隔比等待回复时间长,同一时刻只等一个 pong;关闭动作统一交给后面的 close 处理器决定是否重试。"
+      },
+      {
+        "title": "把重连集中在 close 事件中",
+        "body": "定义下方 connectWs,逐处找到 onopen、onmessage、onerror、onclose。保持重试安排只在 onclose 中,先不额外手工调用 connectWs。",
+        "code": "function connectWs() {\n  if (wsStopped) return;\n  const socket = new WebSocket(\"ws://127.0.0.1:8000/ws\");\n  wsSocket = socket;\n  socket.onopen = () => {\n    if (wsStopped || wsSocket !== socket) { socket.close(); return; }\n    console.log(\"已连接\");\n    startHeartbeat(socket);\n  };\n  socket.onmessage = event => {\n    if (wsSocket !== socket) return;\n    const message = JSON.parse(event.data);\n    retryCount = 0;\n    if (message.type === \"pong\") {\n      clearTimeout(pongTimer);\n      pongTimer = null;\n    } else {\n      console.log(\"收到\", message);\n    }\n  };\n  socket.onerror = () => console.log(\"连接错误,等待 close\");\n  socket.onclose = () => {\n    if (wsSocket !== socket) return;\n    clearHeartbeat();\n    if (wsStopped) return;\n    if (retryCount >= 5) {\n      wsStopped = true;\n      console.log(\"连续重试已达上限,请手动开始\");\n      return;\n    }\n    const delay = Math.min(8000, 500 * 2 ** retryCount) + Math.random() * 200;\n    retryCount += 1;\n    console.log(`第 ${retryCount} 次重试,等待 ${Math.round(delay)}ms`);\n    retryTimer = setTimeout(connectWs, delay);\n  };\n}",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "函数中可见最多 5 次连续失败重试,等待依次约 0.5、1、2、4、8 秒并有小幅随机变化;定义本身不会连上服务。",
+        "explanation": "逐渐延长等待叫退避,随机量叫抖动,用来避免失败时不断密集重连。error 只记录,close 只安排一次重试,防止两个事件各创建一条连接;有效消息到来后才清零失败计数。"
+      },
+      {
+        "title": "实现开始、发送与停止",
+        "body": "执行下方开始、发送和停止函数以及最后的 startWs()。等“已连接”后调用 sendWs(\"陶瓷杯有多大?\"),保持窗口可见至少 6 秒,查看 WS 消息中的 ping/pong。",
+        "code": "function startWs() {\n  if (!wsStopped) return;\n  wsStopped = false;\n  retryCount = 0;\n  connectWs();\n}\nfunction sendWs(text) {\n  if (wsSocket?.readyState !== WebSocket.OPEN) {\n    console.log(\"尚未连接,本条消息未发送\");\n    return;\n  }\n  wsSocket.send(text);\n}\nfunction stopWs() {\n  wsStopped = true;\n  clearTimeout(retryTimer);\n  clearHeartbeat();\n  wsSocket?.close(1000, \"user stopped\");\n  wsSocket = null;\n  console.log(\"已停止\");\n}\nwindow.addEventListener(\"pagehide\", stopWs);\nstartWs();",
+        "lang": "javascript",
+        "filename": "浏览器 Console",
+        "expected": "收到咨询 echo;约 5 秒后看到 ping 和 pong,连接仍保持。再次调用 startWs() 不会额外建立第二条连接。",
+        "explanation": "开始函数防止重复连接;发送前检查 OPEN,未连上时明确报告未发送。停止先设置 wsStopped,再取消全部计时器并关闭连接,因此随后触发 close 时也不会安排重试。"
+      },
+      {
+        "title": "执行三组失败实验",
+        "body": "先确认 sendWs(\"hello\") 能回显。停止 Uvicorn,观察两次重试间隔后尽快重启,待重连成功再发消息;若已达 5 次上限,手动 startWs()。再次停止服务,在下一次重试前执行 stopWs(),等待超过 9 秒并检查 WS 请求列表,最后重启服务供后课使用。",
+        "expected": "断线时显示逐渐延长的重试,恢复后新连接可收发;主动停止后超过 9 秒没有新增 WS。断线期间 sendWs 提示未发送,该文本不会在重连后突然出现。",
+        "explanation": "重连只恢复一条新通道,不恢复旧执行状态,也不自动补送业务消息。本课选择明确拒绝未连接时发送;若将来要求可靠补发,还需要消息编号、确认与服务端去重,不能只加一个重试计时器。"
+      }
+    ],
+    "checks": [
+      "服务停止时重连间隔逐步增加,连续失败达到 5 次重试后停止。",
+      "服务恢复后能建立新连接并再次收到 echo,心跳 ping 对应 pong。",
+      "stopWs 后超过 9 秒没有新连接;未连接时调用 sendWs 不会排队重发。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "每次断线都会产生两条新连接",
+        "cause": "在 error 和 close 中分别启动重试,或开始按钮允许重复创建。",
+        "fix": "只在 close 调度重试;开始前检查当前是否已经运行。"
+      },
+      {
+        "symptom": "点停止后几秒钟又自动连上",
+        "cause": "只关闭 socket,没有取消已经安排的 setTimeout。",
+        "fix": "先设置主动停止标记,再清理重试、心跳和 pong 超时定时器。"
+      },
+      {
+        "symptom": "重连后重复执行了一条操作",
+        "cause": "客户端自动重发消息,但无法确定服务端在断开前是否已经执行。",
+        "fix": "本课不自动重发;需要重试业务操作时另行加入确认、操作编号与幂等处理。"
+      }
+    ],
+    "exercise": {
+      "task": "把连续重试上限改为 3 次,并验证用户手动 startWs() 后可以开始新一轮连接。",
+      "hint": "更改重试边界,保留达到上限时 wsStopped=true,以及开始时 retryCount=0。",
+      "answer": "把 if (retryCount >= 5) 改为 >= 3。连续失败后控制台打印到达上限;随后调用 startWs() 重置次数并重新连接,若服务已恢复即可继续回显。"
+    },
+    "refs": [
+      {
+        "label": "MDN:WebSocket 客户端生命周期",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications"
+      },
+      {
+        "label": "MDN:WebSocket.close",
+        "url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close"
+      },
+      {
+        "label": "RFC 6455:异常关闭后的恢复建议",
+        "url": "https://www.rfc-editor.org/rfc/rfc6455#section-7.2.3"
+      }
+    ],
+    "intro": {
+      "scenario": "咨询连接遇到网络中断后,页面应尝试恢复,但用户点“停止”后就不应继续连接。我们沿用上一课的回显服务,只补齐浏览器端的检测、等待重试与取消。",
+      "startingPoint": [
+        "已完成 API 第 14 课,main.py 的 /ws 能对 ping 返回 pong,对其他文本回 echo;本课不修改服务端路由。",
+        "在本地 /docs 的 Console 执行脚本,先关闭上一课 echoSocket;若要重做整课,刷新页面后从第 1 步开始,避免重复声明 let。",
+        "保留能停止和重启 Uvicorn 的终端;这些中断只针对本机练习服务。"
+      ],
+      "outcome": "让断线后重试间隔逐渐变长,服务恢复后能重新收发,并在手动停止后取消所有后续连接尝试。"
+    }
+  },
+  {
+    "id": "16",
+    "group": "联调与验收",
+    "title": "把能运行的接口变成可重复验收的服务",
+    "summary": "用一条 pytest 命令自动核对商品生命周期、错误、校验值、SSE 与 WebSocket,再用真实网络确认逐段到达和浏览器行为。",
+    "objectives": [
+      "把手工验收的状态码与字段写成断言",
+      "通过返回的真实商品 id 串起创建到删除",
+      "分清进程内测试覆盖范围与仍需浏览器验证的行为"
+    ],
+    "concepts": [
+      {
+        "title": "把观察写成具体断言",
+        "body": "第 2 步不是只检查创建没报错,而是跟踪 id、再次读取更新结果、确认 204 空体及删除后 404。这样的检查才能保护接口行为。"
+      },
+      {
+        "title": "TestClient 不是浏览器或真实网络",
+        "body": "第 1 步无需 Uvicorn 也能测试,with 仍会运行 lifespan;第 4 步验证事件内容,不能据此保证首段延迟或浏览器 CORS。"
+      },
+      {
+        "title": "不同结束方式分别验证",
+        "body": "本课自动检查 422、401、404、SSE done 与 WS 回信;第 5 步另行确认取消和断线停止。正常完成、主动取消和错误不能混为同一结果。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立测试客户端",
+        "body": "在 api-lab 新建 tests 目录和 tests/test_acceptance.py。先在已激活终端运行 python -m pip install pytest httpx,再把下方代码写入文件;第 2–4 步继续追加到同一个文件。先执行 python -m pytest -q tests/test_acceptance.py 检查第一条测试。",
+        "code": "import json\nfrom uuid import uuid4\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom main import app\n\n@pytest.fixture\ndef client(monkeypatch):\n    monkeypatch.setenv(\"DEMO_API_KEY\", \"classroom-key\")\n    with TestClient(app) as test_client:\n        yield test_client\n\ndef test_health(client):\n    response = client.get(\"/health\")\n    assert response.status_code == 200",
+        "lang": "python",
+        "filename": "tests/test_acceptance.py",
+        "expected": "目前看到 1 passed;若导入 main 失败,确认命令从 api-lab 根目录运行。测试不需要先在 8000 启动 Uvicorn。",
+        "explanation": "TestClient 在进程内调用 app,assert 检查真实响应状态;pytest.fixture 为每个测试提供客户端。with 会运行 lifespan,monkeypatch 只在测试期间设置 classroom-key,结束后恢复。"
+      },
+      {
+        "title": "验证创建、查询、更新与删除",
+        "body": "把下面生命周期测试追加到同一个文件,再执行相同 pytest 命令。保留从 POST 响应读取 id 的过程;PUT 明确提供 name、description、is_active、price_cents 四个可写字段。",
+        "code": "def test_product_lifecycle(client):\n    name = f\"acceptance-{uuid4()}\"\n    created = client.post(\"/products\", json={\"name\": name})\n    assert created.status_code == 201\n    product = created.json()\n    product_id = product[\"id\"]\n    assert product[\"description\"] == \"\"\n    assert product[\"is_active\"] is False\n    assert product[\"price_cents\"] == 0\n    assert client.get(f\"/products/{product_id}\").json() == product\n\n    listed = client.get(\"/products\", params={\"limit\": 100})\n    assert listed.status_code == 200\n    assert any(item[\"id\"] == product_id for item in listed.json())\n\n    replacement = {\"name\": \"更新后\", \"description\": \"测试描述\", \"is_active\": True, \"price_cents\": 0}\n    updated = client.put(f\"/products/{product_id}\", json=replacement)\n    assert updated.status_code == 200\n    assert updated.json() == {\"id\": product_id, **replacement}\n    assert client.get(f\"/products/{product_id}\").json() == updated.json()\n\n    deleted = client.delete(f\"/products/{product_id}\")\n    assert deleted.status_code == 204\n    assert deleted.content == b\"\"\n    missing = client.get(f\"/products/{product_id}\")\n    assert missing.status_code == 404\n    assert missing.json()[\"error\"][\"code\"] == \"http_error\"",
+        "lang": "python",
+        "filename": "tests/test_acceptance.py",
+        "expected": "累计 2 passed;断言同时检查 201、默认字段、列表里出现编号、PUT 后再次读取一致、DELETE 空体和删除后的 404。",
+        "explanation": "每个测试用唯一名称创建自己的商品,避免依赖 id=1 或已有样本。断言检查后续可观察结果,而不只看请求未抛异常。DELETE 的 204 响应没有 JSON,所以检查 deleted.content 为空字节。"
+      },
+      {
+        "title": "验证输入错误与受保护接口",
+        "body": "继续追加输入校验与 API Key 测试,再运行同一命令。遇到失败时读失败行及 expected/actual,先与第 07、08 课接口约定对照。",
+        "code": "def test_validation(client):\n    invalid = client.post(\"/products\", json={\"name\": \"\"})\n    assert invalid.status_code == 422\n    assert invalid.json()[\"error\"][\"code\"] == \"validation_error\"\n    assert client.get(\"/slow\", params={\"delay\": -1}).status_code == 422\n\n\ndef test_api_key(client):\n    assert client.get(\"/private\").status_code == 401\n    wrong = client.get(\"/private\", headers={\"X-API-Key\": \"wrong-key\"})\n    assert wrong.status_code == 401\n    assert wrong.json()[\"error\"][\"code\"] == \"http_error\"\n    valid = client.get(\"/private\", headers={\"X-API-Key\": \"classroom-key\"})\n    assert valid.status_code == 200",
+        "lang": "python",
+        "filename": "tests/test_acceptance.py",
+        "expected": "累计 4 passed:空名称和负 delay 为 422,缺失/错误 Key 为 401,正确 Key 为 200;error.code 保持统一约定。",
+        "explanation": "成功路径通过还不够,错误路径也是服务行为的一部分。这里只有 /private 需要课堂 Key,商品接口仍按本地练习设计开放;测试把这两个边界分别检查。"
+      },
+      {
+        "title": "验证 SSE 内容和 WebSocket 来回消息",
+        "body": "把 SSE 与 WebSocket 测试追加到文件末尾,再次运行 pytest。若做过 stats 练习可保留它,但 done 必须最后出现;不要修改断言去容忍缺少完成事件。",
+        "code": "def test_stream_matches_complete_result(client):\n    complete = client.post(\"/generate\", json={\"prompt\": \"catalog\"})\n    assert complete.status_code == 200\n    response = client.get(\"/stream\", params={\"prompt\": \"catalog\"})\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"].startswith(\"text/event-stream\")\n    events = []\n    for block in response.text.replace(\"\\r\\n\", \"\\n\").strip().split(\"\\n\\n\"):\n        fields = {}\n        for line in block.split(\"\\n\"):\n            name, value = line.split(\":\", 1)\n            fields[name] = value.lstrip(\" \")\n        events.append((fields[\"event\"], json.loads(fields[\"data\"])))\n    assert [name for name, _ in events if name != \"stats\"] == [\"token\", \"token\", \"token\", \"done\"]\n    text = \"\".join(data[\"text\"] for name, data in events if name == \"token\")\n    assert text == complete.json()[\"text\"]\n    assert events[-1] == (\"done\", {})\n\n\ndef test_websocket(client):\n    with client.websocket_connect(\"/ws\") as socket:\n        socket.send_text(\"hello\")\n        assert socket.receive_json() == {\"type\": \"echo\", \"text\": \"hello\"}\n        socket.send_text(\"ping\")\n        assert socket.receive_json() == {\"type\": \"pong\"}",
+        "lang": "python",
+        "filename": "tests/test_acceptance.py",
+        "expected": "累计 6 passed;SSE 拼接的文本与 /generate 完整结果一致,主线是三个 token 后 done;WS 的 hello/echo 与 ping/pong 都匹配。",
+        "explanation": "这次 TestClient 收到有限 SSE 的完整内容后检查格式,不证明真实网络按 0.4 秒逐段到达。WebSocket 上下文结束时关闭连接;流要检查 done,不能只检查有几段文字。"
+      },
+      {
+        "title": "运行自动验收,再测真实网络",
+        "body": "最终运行 python -m pytest -q tests/test_acceptance.py。通过后启动一个 Uvicorn 进程,将下方 Python 保存为 api-lab/check_stream_timing.py,在另一个终端执行 python check_stream_timing.py。再按第 10、13、15 课分别复验 CORS、取消流与断线后主动停止。",
+        "code": "from time import perf_counter\nimport httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5.0) as client:\n    started = perf_counter()\n    with client.stream(\"GET\", \"/stream\", params={\"prompt\": \"catalog\"}) as response:\n        response.raise_for_status()\n        for line in response.iter_lines():\n            if line.startswith(\"data:\"):\n                print(f\"{perf_counter() - started:.2f}s {line}\", flush=True)",
+        "lang": "python",
+        "filename": "check_stream_timing.py",
+        "expected": "pytest 为 6 passed;真实计时脚本的 token data 通常约在 0.4、0.8、1.2 秒出现,done 紧随末段。浏览器三个场景也与各课预期一致;网络时间允许有合理开销。",
+        "explanation": "进程内测试适合快速检查状态和数据,真实 HTTPX 计时检查逐段到达,浏览器检查 CORS 与页面生命周期。三者覆盖不同层面,不能用一份通过结果代替所有体验验收。"
+      }
+    ],
+    "checks": [
+      "自动测试覆盖健康检查、完整 CRUD、422 校验、401 认证、删除后 404、SSE 与 WebSocket,运行结果全部通过。",
+      "真实 HTTPX / curl 客户端在响应结束前接收到 token,浏览器停止读取后服务端执行清理。",
+      "允许来源的浏览器能够调用 API;未允许来源不能读取响应;WebSocket 主动停止后不再重连。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "单个测试能过,整套测试偶尔失败",
+        "cause": "多个测试假定相同 id 或依赖前一个测试创建的数据。",
+        "fix": "每个测试自行创建记录,用返回 id 操作,并在生命周期测试中删除。"
+      },
+      {
+        "symptom": "测试里 app.state.http 不存在",
+        "cause": "没有通过 with TestClient(app) 启动 lifespan,或代码未正确注册 lifespan。",
+        "fix": "在 fixture 的上下文中创建客户端,并核对 FastAPI(lifespan=lifespan)。"
+      },
+      {
+        "symptom": "SSE 测试通过,网页仍一次显示全部内容",
+        "cause": "进程内测试只检查完整内容,没有经过浏览器、真实网络和中间服务。",
+        "fix": "用真实 Uvicorn 配合 httpx.stream / curl -N 观察时间,再检查浏览器读取与渲染。"
+      }
+    ],
+    "exercise": {
+      "task": "增加一个测试:创建商品后,PUT 只发送 name 应返回 422,随后 GET 仍返回原来的完整内容。",
+      "hint": "输入不合法时不能发生部分更新;先保存创建响应,再比较读取结果。",
+      "answer": "创建商品并保存 product;使用 client.put(f'/products/{product[\"id\"]}', json={\"name\":\"修改\"}),断言 422;再次 GET 并断言等于 product,最后 DELETE 清理。这个测试同时检查完整替换约定和校验失败不写入的行为。"
+    },
+    "refs": [
+      {
+        "label": "FastAPI:TestClient 与 pytest",
+        "url": "https://fastapi.tiangolo.com/tutorial/testing/"
+      },
+      {
+        "label": "FastAPI:测试 lifespan",
+        "url": "https://fastapi.tiangolo.com/advanced/testing-events/"
+      },
+      {
+        "label": "FastAPI:测试 WebSocket",
+        "url": "https://fastapi.tiangolo.com/advanced/testing-websockets/"
+      },
+      {
+        "label": "pytest:fixtures",
+        "url": "https://docs.pytest.org/en/stable/how-to/fixtures.html"
+      },
+      {
+        "label": "pytest:临时环境变量",
+        "url": "https://docs.pytest.org/en/stable/how-to/monkeypatch.html"
+      },
+      {
+        "label": "HTTPX:流式响应",
+        "url": "https://www.python-httpx.org/quickstart/"
+      }
+    ],
+    "intro": {
+      "scenario": "商品接口、清单进度和咨询连接都已经能手工演示。现在把重要观察写成每次都可执行的检查,防止以后改一处代码却悄悄破坏另一项功能。",
+      "startingPoint": [
+        "已完成 API 第 01–15 课,api-lab 中保留完整商品 CRUD、统一错误、/private、/slow、/generate、GET /stream 和 /ws。",
+        "确认 /slow 已恢复异步 sleep,/stream 有三个 token 与最后 done,手工停止实验已结束。",
+        "本课新增 tests/test_acceptance.py 和一个真实网络计时脚本;先用原 .venv 安装 pytest、httpx,再运行。"
+      ],
+      "outcome": "用一条 pytest 命令自动核对商品生命周期、错误、校验值、SSE 与 WebSocket,再用真实网络确认逐段到达和浏览器行为。"
+    }
+  }
+]

+ 72 - 15
app/module-catalog.ts

@@ -7,6 +7,8 @@ import {
   Container,
   Server,
   Activity,
+  Network,
+  ClipboardCheck,
 } from 'lucide-react';
 export const modules = [
   {
@@ -18,10 +20,13 @@ export const modules = [
     stack: 'FastAPI · HTTP · SSE · WebSocket',
     description:
       '从 Python 函数到可调用的服务,完成参数校验、REST 接口、异步调用、流式响应与双向通信。',
-    project: '笔记与消息服务',
+    project: '电商商品服务',
     scope:
-      '用内存版服务理解接口行为。生产中的数据持久化、连接预算和异步任务由后续模块逐步补齐。',
-    prerequisites: ['Python 函数、列表与字典', '能创建虚拟环境、执行终端命令'],
+      '用商品名称、描述、价格和上架状态练习内存版接口,后续逐步补齐持久化、缓存和商品清单导出。业务围绕商品目录展开。',
+    prerequisites: [
+      'Python 函数、列表与字典',
+      '已安装 Python,能用编辑器保存文件',
+    ],
     depends: [],
     outcomes: [
       '从空目录启动服务,用 /docs 和 curl 验证接口。',
@@ -38,16 +43,17 @@ export const modules = [
     kicker: '数据与事务',
     stack: 'MySQL 8.4 LTS · InnoDB · PyMySQL',
     description:
-      '建立有约束的数据模型,编写查询,在并发和故障下验证事务、锁、索引与数据恢复。',
-    project: 'notes_lab 数据库',
+      '从商品与分类的需求推导表结构,选择字段类型、主外键和约束,再验证查询、事务、索引与数据恢复。',
+    project: 'shop_lab 商品与分类数据库',
     scope:
       '从表结构和 SQL 开始,完成查询计划分析、并发事务实验、最小权限与可恢复的备份。',
     prerequisites: [
       '已完成 API 服务开发',
-      'MySQL 8.4 LTS 与独立 notes_lab 练习库',
+      '已安装并可运行 Docker;第 01 课会创建 MySQL 练习库',
     ],
     depends: ['api'],
     outcomes: [
+      '从读写需求设计商品表、分类表和关联表,解释金额、主外键与删除策略。',
       '用数据库约束保护数据,而非仅依赖应用校验。',
       '使用两个会话复现隔离、锁等待和死锁。',
       '根据 EXPLAIN 与实际数据选择索引和查询方式。',
@@ -62,8 +68,8 @@ export const modules = [
     kicker: '持久化服务',
     stack: 'SQLAlchemy 2 · Alembic · MySQL',
     description:
-      '把笔记接口接入数据库,明确会话与事务边界,处理并发更新、幂等请求和兼容性迁移。',
-    project: 'notes-service 持久化 API',
+      '把商品接口接入数据库,明确会话与事务边界,处理并发更新、幂等请求和兼容性迁移。',
+    project: 'shop-service 持久化 API',
     scope:
       '以请求生命周期组织连接、事务和错误;用版本字段、唯一约束与 outbox 处理跨请求及跨进程一致性。',
     prerequisites: ['REST 接口与 Pydantic 模型', 'SQL、事务和数据库连接'],
@@ -84,12 +90,12 @@ export const modules = [
     stack: 'asyncio · threading · multiprocessing · Celery',
     description:
       '根据 I/O、CPU、隔离与持久性要求选择执行方式,把长任务变成可限流、可重试、可恢复的任务系统。',
-    project: '笔记导出与长文处理任务',
+    project: '商品清单导出与描述统计任务',
     scope:
       '从事件循环、线程和进程开始,再引入任务队列、数据库状态、outbox、工作租约和故障恢复。',
     prerequisites: [
       'API 与数据库事务',
-      'Linux / WSL2 运行 Celery worker;Redis 基本连接',
+      '前 07 课使用本机 Python;第 08 课起准备 Linux / WSL2 与 Docker',
     ],
     depends: ['api-database'],
     outcomes: [
@@ -108,10 +114,13 @@ export const modules = [
     stack: 'Redis 7 · redis-py · Cache Aside',
     description:
       '为读请求引入缓存,明确数据真源、失效窗口和容量边界,复现穿透、击穿、雪崩与 Redis 故障。',
-    project: 'notes-service 查询缓存',
+    project: 'shop-service 查询缓存',
     scope:
       '缓存只承载可重建数据;围绕更新失效、热点保护、并发控制、内存策略与故障降级设计实现。',
-    prerequisites: ['持久化 API 与事务提交边界', 'Redis 独立练习实例'],
+    prerequisites: [
+      '已完成持久化商品接口',
+      '可用的 Docker;第 01 课会创建 Redis 练习实例',
+    ],
     depends: ['api-database'],
     outcomes: [
       '实现包含 TTL 与键版本的缓存读取和更新失效。',
@@ -129,7 +138,7 @@ export const modules = [
     stack: 'Docker Engine · Compose · OCI Images',
     description:
       '构建可复现镜像,组合 API、数据库、缓存与 worker,管理数据、网络、资源、健康检查与升级。',
-    project: 'notes-service 容器运行环境',
+    project: 'shop-service 容器运行环境',
     scope:
       '区分镜像、容器和持久卷;用非 root 运行、固定依赖、资源上限、迁移作业与可回滚发布管理服务。',
     prerequisites: [
@@ -153,7 +162,7 @@ export const modules = [
     stack: 'Linux · systemd · Nginx · TLS',
     description:
       '把本地可运行服务部署到 Linux,建立权限边界、进程管理、反向代理、发布回滚与备份恢复流程。',
-    project: 'notes-service Linux 部署',
+    project: 'shop-service Linux 部署',
     scope:
       '以独立练习主机为环境,验证服务启动、网络入口、流式代理、凭证权限、发布检查与恢复手册。',
     prerequisites: ['容器与服务网络', '独立 Linux 练习机,使用普通账户与 sudo'],
@@ -174,7 +183,7 @@ export const modules = [
     stack: 'Logs · Metrics · Traces · Runbooks',
     description:
       '从请求症状建立证据链,定位 CPU、内存、连接、数据库、缓存与队列问题,验证止损和修复效果。',
-    project: 'notes-service 故障演练环境',
+    project: 'shop-service 故障演练环境',
     scope:
       '先定义影响范围,再观察指标、日志和追踪;在隔离环境复现故障,记录恢复操作与复发条件。',
     prerequisites: [
@@ -189,6 +198,54 @@ export const modules = [
       '验证恢复门槛、回滚、容量改进与复盘行动。',
     ],
   },
+  {
+    slug: 'distributed',
+    name: '分布式系统设计',
+    icon: Network,
+    count: 12,
+    kicker: '架构原理与面试推演',
+    stack: '架构模式 · 一致性 · 可靠性 · 容量设计',
+    description:
+      '从商品查询与清单导出的跨进程链路出发,理解常见分布式架构、失败模式和解决方案。用场景推演与面试问答练习设计取舍。',
+    project: '电商商品服务系统设计案例',
+    scope:
+      '纯理论模块,无环境安装和实战代码。围绕组件职责、故障时间线、方案代价与适用边界进行分析;能说清为什么选择,何时需要调整。',
+    prerequisites: [
+      '了解 API、数据库、缓存与队列各自的职责',
+      '建议先阅读课程总览与相关模块图解',
+    ],
+    depends: ['api-database', 'tasks', 'redis'],
+    outcomes: [
+      '根据业务约束比较模块化单体、微服务和事件驱动架构。',
+      '用具体故障解释一致性、重复消息、未知结果与缓存失效。',
+      '说明幂等、事务、租约锁、重试与熔断各自解决的问题及边界。',
+      '围绕容量、可观测性、RPO 与 RTO 完成有依据的系统设计回答。',
+    ],
+  },
+  {
+    slug: 'review',
+    name: '复盘与自测',
+    icon: ClipboardCheck,
+    count: 9,
+    kicker: '掌握标准与回学',
+    stack: '理解问题 · 情境推演 · 掌握证据',
+    description:
+      '逐一回顾前面九个模块:先独立回答核心问题,再对照思考要点,以可验证的成果判断是否掌握,并找到需要回学的课时。',
+    project: '电商商品服务 · 九模块能力复盘',
+    scope:
+      '每个单元对应一个技术模块,包含六个理解问题、两个情境推演和四项掌握标准。先作答后展开要点,自评与回答保存在当前浏览器,可按薄弱项返回原课时。',
+    prerequisites: [
+      '完成想要自测的对应模块即可进入',
+      '准备自己的练习记录;先闭卷回答,再核对参考要点',
+    ],
+    depends: [],
+    outcomes: [
+      '能够独立解释各模块的核心机制,并用商品服务举例。',
+      '条件改变或出现故障时,能推演结果、说明取舍和验证方法。',
+      '每个模块的四项掌握标准都有实际操作或设计推演证据。',
+      '将未掌握的问题对应到具体课时,补学后重新闭卷自测。',
+    ],
+  },
 ] as const;
 export type ModuleSlug = (typeof modules)[number]['slug'];
 export type ModuleInfo = (typeof modules)[number];

+ 407 - 0
app/review-page.tsx

@@ -0,0 +1,407 @@
+'use client';
+
+import { useEffect, useState, type MouseEvent, type ReactNode } from 'react';
+import { ArrowLeft, ArrowRight, RotateCcw } from 'lucide-react';
+import type { Lesson, ReviewItem, ReviewLesson } from './lesson-types';
+import { getModule } from './module-catalog';
+import { courseHref } from './course-navigation';
+import {
+  emptyReviewState,
+  restoreReviewState,
+  reviewProgress,
+  reviewSignature,
+  type ReviewState,
+} from './review-state';
+import './review.css';
+
+type Navigate = (module: string, lesson?: string) => void;
+function ReviewLink({
+  module,
+  lesson,
+  navigate,
+  children,
+  className,
+}: {
+  module: string;
+  lesson?: string;
+  navigate: Navigate;
+  children: ReactNode;
+  className?: string;
+}) {
+  return (
+    <a
+      href={courseHref(module, lesson)}
+      className={className}
+      onClick={(event: MouseEvent<HTMLAnchorElement>) => {
+        if (
+          event.button !== 0 ||
+          event.metaKey ||
+          event.ctrlKey ||
+          event.shiftKey ||
+          event.altKey
+        )
+          return;
+        event.preventDefault();
+        navigate(module, lesson);
+      }}
+    >
+      {children}
+    </a>
+  );
+}
+
+export function ReviewPage({
+  lesson,
+  lessons,
+  navigate,
+}: {
+  lesson: ReviewLesson;
+  lessons: Lesson[];
+  navigate: Navigate;
+}) {
+  const [state, setState] = useState<ReviewState>(emptyReviewState);
+  const [loaded, setLoaded] = useState(false);
+  const [storageUnavailable, setStorageUnavailable] = useState(false);
+  const [attempt, setAttempt] = useState(0);
+  const storageKey = `shuhe:module-review:v1:${lesson.sourceModule}`;
+  useEffect(() => {
+    try {
+      // oxlint-disable-next-line react/react-compiler -- Restore this browser's saved answers after hydration; server rendering cannot read localStorage.
+      setState(restoreReviewState(localStorage.getItem(storageKey), lesson));
+    } catch {
+      setStorageUnavailable(true);
+    }
+    setLoaded(true);
+  }, [lesson, storageKey]);
+  function update(next: ReviewState) {
+    setState(next);
+    try {
+      localStorage.setItem(
+        storageKey,
+        JSON.stringify({
+          sourceModule: lesson.sourceModule,
+          signature: reviewSignature(lesson),
+          state: next,
+        }),
+      );
+    } catch {
+      setStorageUnavailable(true);
+    }
+  }
+  const progress = reviewProgress(lesson, state);
+  const source = getModule(lesson.sourceModule);
+  const index = lessons.findIndex((item) => item.id === lesson.id);
+  function question(item: ReviewItem, order: number, challenge = false) {
+    const prefix = challenge ? '情境' : '问题';
+    return (
+      <article
+        className="review-question"
+        id={`review-${item.id}`}
+        key={`${item.id}-${attempt}`}
+      >
+        <div className="review-question-number">
+          {prefix} {String(order + 1).padStart(2, '0')}
+        </div>
+        <h3>{item.title}</h3>
+        <p className="review-prompt">{item.prompt}</p>
+        <details className="review-notes">
+          <summary>记下我的回答(可选)</summary>
+          <label htmlFor={`review-note-${item.id}`}>
+            先写判断、原因和验证方式,再看参考要点。
+          </label>
+          <textarea
+            id={`review-note-${item.id}`}
+            rows={4}
+            maxLength={4000}
+            disabled={!loaded}
+            value={state.notes[item.id] || ''}
+            onChange={(event) =>
+              update({
+                ...state,
+                notes: { ...state.notes, [item.id]: event.target.value },
+              })
+            }
+          />
+        </details>
+        <details className="review-answer">
+          <summary>展开参考要点与易错点</summary>
+          <div>
+            <p>
+              对照推理是否完整。表述可以不同;能够解释原因、边界与验证方法,才算独立答出。
+            </p>
+            <ul>
+              {item.checkpoints.map((point) => (
+                <li key={point}>{point}</li>
+              ))}
+            </ul>
+            <p className="review-misconception">
+              <strong>容易误判:</strong>
+              {item.misconception}
+            </p>
+          </div>
+        </details>
+        <fieldset className="review-rating" disabled={!loaded}>
+          <legend>
+            {challenge
+              ? '合上要点后,我能独立推演这个变化吗?'
+              : '合上要点后,我能用自己的话解释吗?'}
+          </legend>
+          <label>
+            <input
+              type="radio"
+              name={`review-rating-${item.id}`}
+              value="revisit"
+              checked={state.answers[item.id] === 'revisit'}
+              onChange={() =>
+                update({
+                  ...state,
+                  answers: { ...state.answers, [item.id]: 'revisit' },
+                })
+              }
+            />
+            还需要回学
+          </label>
+          <label>
+            <input
+              type="radio"
+              name={`review-rating-${item.id}`}
+              value="ready"
+              checked={state.answers[item.id] === 'ready'}
+              onChange={() =>
+                update({
+                  ...state,
+                  answers: { ...state.answers, [item.id]: 'ready' },
+                })
+              }
+            />
+            {challenge ? '能独立推演并说明取舍' : '能独立解释并举例'}
+          </label>
+        </fieldset>
+        <div className="review-revisit">
+          <span>需要时回学:</span>
+          {item.revisit.map((link) => (
+            <ReviewLink
+              key={link.lessonId}
+              module={lesson.sourceModule}
+              lesson={link.lessonId}
+              navigate={navigate}
+            >
+              {link.lessonId} · {link.label}
+              <ArrowRight size={13} />
+            </ReviewLink>
+          ))}
+        </div>
+      </article>
+    );
+  }
+  return (
+    <article className="lesson-article review-page">
+      <ReviewLink module="review" navigate={navigate} className="back-link">
+        <ArrowLeft size={14} />
+        复盘与自测 · 全部单元
+      </ReviewLink>
+      <div className="eyebrow">
+        REVIEW {lesson.id} / {lessons.length}
+        <span />
+        {lesson.group}
+      </div>
+      <h1 className="lesson-title" tabIndex={-1}>
+        {lesson.title}
+      </h1>
+      <p className="lead">{lesson.summary}</p>
+      <div className="meta-row">
+        <span>{source?.name}</span>
+        <span>{lesson.questions.length} 个理解问题</span>
+        <span>{lesson.challenges.length} 个情境推演</span>
+      </div>
+      <section id="review-start" className="lesson-start">
+        <div className="section-heading">
+          <h2>先独立回答,再对照</h2>
+        </div>
+        <p className="lesson-scenario">{lesson.intro.scenario}</p>
+        <ul>
+          {lesson.intro.startingPoint.map((item) => (
+            <li key={item}>{item}</li>
+          ))}
+        </ul>
+        <p className="lesson-outcome">
+          <strong>本轮目标</strong>
+          {lesson.intro.outcome}
+        </p>
+        <ol className="review-method">
+          <li>先合上原课件,用自己的话回答;需要时记下思路。</li>
+          <li>
+            展开要点找遗漏,再合上要点重述;依赖提示才能答出的题标为“还需要回学”。
+          </li>
+          <li>
+            完成情境推演,并核对四项掌握标准的实际证据。阅读过答案本身不作为掌握依据。
+          </li>
+        </ol>
+        {state.contentChanged && (
+          <p className="learning-section-note">
+            问题或掌握标准已有更新,旧笔记已保留供参考;旧自评已清除,请按本轮内容重新回答和核对证据。
+          </p>
+        )}
+        <p className="learning-section-note">
+          {storageUnavailable
+            ? '当前浏览器无法保存记录,回答暂留在本页;离开前请自行保存。'
+            : '回答与自评自动保存在当前浏览器,可下次继续。更换浏览器或清除浏览数据后需要重新记录。'}
+        </p>
+      </section>
+      <section id="review-questions">
+        <div className="section-heading">
+          <h2>核心理解问题</h2>
+          <span>说清发生了什么,以及为什么</span>
+        </div>
+        {lesson.questions.map((item, i) => question(item, i))}
+      </section>
+      <section id="review-challenges">
+        <div className="section-heading">
+          <h2>换个情况,再想一步</h2>
+          <span>条件变化后,判断还成立吗</span>
+        </div>
+        {lesson.challenges.map((item, i) => question(item, i, true))}
+      </section>
+      <section id="review-mastery">
+        <div className="section-heading">
+          <h2>达到什么程度,算掌握这个模块</h2>
+        </div>
+        <p>
+          每项都需要能拿出下面的证据。操作类证据在自己的练习环境验证;设计类证据用图、推演或文字说明。
+        </p>
+        <fieldset className="review-evidence" disabled={!loaded}>
+          <legend className="sr-only">掌握标准与证据</legend>
+          {lesson.mastery.map((item) => (
+            <label key={item.id} aria-label={item.text}>
+              <input
+                type="checkbox"
+                checked={!!state.evidence[item.id]}
+                onChange={(event) =>
+                  update({
+                    ...state,
+                    evidence: {
+                      ...state.evidence,
+                      [item.id]: event.target.checked,
+                    },
+                  })
+                }
+              />
+              <span>
+                <strong>{item.text}</strong>
+                <span>{item.evidence}</span>
+              </span>
+            </label>
+          ))}
+        </fieldset>
+      </section>
+      <section id="review-result">
+        <div className="section-heading">
+          <h2>本轮自测结果与回学计划</h2>
+        </div>
+        <div
+          className={`review-result${progress.mastered ? ' review-result-ready' : ''}`}
+          aria-live="polite"
+        >
+          <strong>
+            {progress.mastered
+              ? '按你的自评,已达到本模块的掌握标准'
+              : '本轮还有待验证的部分'}
+          </strong>
+          <p>
+            独立回答 {progress.ready} / {progress.total} 项 · 掌握证据{' '}
+            {progress.evidence} / {progress.evidenceTotal} 项
+          </p>
+          <p>
+            {progress.mastered
+              ? '请隔一天换一个商品或故障条件,再闭卷推演一次,确认能够复用。这里记录的是你的自评,不对文字答案自动判分。'
+              : '理解问题和情境推演都能独立完成,且四项证据均已核对,才达到本轮标准。先补齐下面的缺口,再回来自测。'}
+          </p>
+        </div>
+        {progress.revisit.length > 0 && (
+          <ul className="review-gaps">
+            {progress.revisit.map((item) => (
+              <li key={item.id}>
+                <a href={`#review-${item.id}`}>{item.title}</a>
+                <span>
+                  {state.answers[item.id] === 'revisit'
+                    ? '已标记需回学'
+                    : '尚未自评'}
+                </span>
+              </li>
+            ))}
+          </ul>
+        )}
+        {lesson.mastery.filter((item) => !state.evidence[item.id]).length >
+          0 && (
+          <div className="review-pending-evidence">
+            <h3>还需要补齐的掌握证据</h3>
+            <ul>
+              {lesson.mastery
+                .filter((item) => !state.evidence[item.id])
+                .map((item) => (
+                  <li key={item.id}>
+                    <a href="#review-mastery">{item.text}</a>
+                  </li>
+                ))}
+            </ul>
+          </div>
+        )}
+        <div className="review-result-actions">
+          <ReviewLink
+            module={lesson.sourceModule}
+            navigate={navigate}
+            className="secondary-link"
+          >
+            回到{source?.name}
+            <ArrowRight size={15} />
+          </ReviewLink>
+          <button
+            type="button"
+            className="review-reset"
+            disabled={!loaded}
+            onClick={() => {
+              update(emptyReviewState());
+              setAttempt((value) => value + 1);
+            }}
+          >
+            <RotateCcw size={14} />
+            清除本单元回答与自评,重新开始
+          </button>
+        </div>
+      </section>
+      <div className="lesson-pagination">
+        <ReviewLink
+          module="review"
+          lesson={index > 0 ? lessons[index - 1].id : undefined}
+          navigate={navigate}
+        >
+          <span>
+            <ArrowLeft size={13} />
+            {index > 0 ? '上一单元' : '复盘概览'}
+          </span>
+          <strong>
+            {index > 0 ? lessons[index - 1].title : '选择要自测的模块'}
+          </strong>
+        </ReviewLink>
+        <ReviewLink
+          module="review"
+          lesson={
+            index < lessons.length - 1 ? lessons[index + 1].id : undefined
+          }
+          navigate={navigate}
+          className="next"
+        >
+          <span>
+            {index < lessons.length - 1 ? '下一单元' : '回到复盘概览'}
+            <ArrowRight size={13} />
+          </span>
+          <strong>
+            {index < lessons.length - 1
+              ? lessons[index + 1].title
+              : '选择下一轮要巩固的模块'}
+          </strong>
+        </ReviewLink>
+      </div>
+    </article>
+  );
+}

+ 82 - 0
app/review-state.ts

@@ -0,0 +1,82 @@
+import type { ReviewLesson } from './lesson-types';
+
+export type ReviewRating = 'revisit' | 'ready';
+export type ReviewState = {
+  answers: Record<string, ReviewRating>;
+  notes: Record<string, string>;
+  evidence: Record<string, boolean>;
+  contentChanged: boolean;
+};
+export function emptyReviewState(): ReviewState {
+  return { answers: {}, notes: {}, evidence: {}, contentChanged: false };
+}
+// Invalidate old self-ratings whenever their questions or mastery criteria change.
+export function reviewSignature(lesson: ReviewLesson) {
+  const assessable = (item: ReviewLesson['questions'][number]) => ({
+    id: item.id,
+    prompt: item.prompt,
+    checkpoints: item.checkpoints,
+    misconception: item.misconception,
+  });
+  const text = JSON.stringify({
+    sourceModule: lesson.sourceModule,
+    questions: lesson.questions.map(assessable),
+    challenges: lesson.challenges.map(assessable),
+    mastery: lesson.mastery,
+  });
+  let hash = 2166136261;
+  for (let i = 0; i < text.length; i++) {
+    hash ^= text.charCodeAt(i);
+    hash = Math.imul(hash, 16777619);
+  }
+  return (hash >>> 0).toString(16);
+}
+export function restoreReviewState(
+  raw: string | null,
+  lesson: ReviewLesson,
+): ReviewState {
+  const state = emptyReviewState();
+  if (!raw) return state;
+  try {
+    const saved = JSON.parse(raw);
+    if (saved?.sourceModule !== lesson.sourceModule || !saved.state)
+      return state;
+    const sameContent = saved.signature === reviewSignature(lesson);
+    state.contentChanged = !sameContent || saved.state.contentChanged === true;
+    for (const item of [...lesson.questions, ...lesson.challenges]) {
+      const value = saved.state?.answers?.[item.id];
+      if (sameContent && (value === 'revisit' || value === 'ready'))
+        state.answers[item.id] = value;
+      const note = saved.state?.notes?.[item.id];
+      if (typeof note === 'string') state.notes[item.id] = note.slice(0, 4000);
+    }
+    for (const item of lesson.mastery) {
+      if (sameContent && saved.state?.evidence?.[item.id] === true)
+        state.evidence[item.id] = true;
+    }
+  } catch {
+    // Corrupt or unavailable browser storage must not prevent studying.
+  }
+  return state;
+}
+export function reviewProgress(lesson: ReviewLesson, state: ReviewState) {
+  const items = [...lesson.questions, ...lesson.challenges];
+  const ready = items.filter(
+    (item) => state.answers[item.id] === 'ready',
+  ).length;
+  const evidence = lesson.mastery.filter(
+    (item) => state.evidence[item.id] === true,
+  ).length;
+  return {
+    ready,
+    total: items.length,
+    evidence,
+    evidenceTotal: lesson.mastery.length,
+    mastered:
+      items.length > 0 &&
+      lesson.mastery.length > 0 &&
+      ready === items.length &&
+      evidence === lesson.mastery.length,
+    revisit: items.filter((item) => state.answers[item.id] !== 'ready'),
+  };
+}

+ 225 - 0
app/review.css

@@ -0,0 +1,225 @@
+.review-page section {
+  scroll-margin-top: 96px;
+}
+.review-method {
+  padding-left: 1.5rem;
+  list-style: decimal;
+}
+.review-answer ul,
+#review-start ul,
+.review-pending-evidence ul {
+  list-style: disc;
+  padding-left: 1.4rem;
+}
+.review-method li {
+  margin: 0.5rem 0;
+}
+.review-question {
+  margin: 1.5rem 0;
+  padding: 1.5rem;
+  border: 1px solid var(--line, #dfe5ed);
+  border-radius: 8px;
+  scroll-margin-top: 96px;
+}
+.review-question-number {
+  color: #526277;
+  font-size: 11px;
+  letter-spacing: 0.1em;
+  margin-bottom: 0.5rem;
+}
+.review-question h3 {
+  margin: 0 0 0.75rem;
+}
+.review-prompt {
+  white-space: pre-line;
+}
+.review-notes,
+.review-answer {
+  margin-top: 1rem;
+  border-top: 1px solid #e6eaf0;
+  padding-top: 1rem;
+}
+.review-page summary {
+  cursor: pointer;
+  font-weight: 600;
+  color: #244e90;
+}
+.review-page summary:focus-visible,
+.review-page input:focus-visible,
+.review-page textarea:focus-visible,
+.review-page button:focus-visible {
+  outline: 2px solid #346def;
+  outline-offset: 4px;
+}
+.review-notes label {
+  display: block;
+  margin: 1rem 0 0.5rem;
+  font-size: 13px;
+}
+.review-notes textarea {
+  display: block;
+  width: 100%;
+  resize: vertical;
+  padding: 0.75rem;
+  border: 1px solid #aab7c9;
+  border-radius: 4px;
+  background: #fff;
+  color: #172b48;
+  font: inherit;
+  line-height: 1.7;
+}
+.review-answer > div {
+  margin-top: 1rem;
+  padding: 1rem 1.1rem;
+  background: #f4f7fc;
+  border-radius: 4px;
+}
+.review-answer li {
+  margin: 0.6rem 0;
+}
+.review-misconception {
+  border-left: 3px solid #b27b28;
+  padding-left: 0.8rem;
+}
+.review-rating {
+  margin: 1.25rem 0 0;
+  padding: 0;
+  border: 0;
+  display: flex;
+  gap: 0.6rem 1.3rem;
+  flex-wrap: wrap;
+}
+.review-rating legend {
+  margin-bottom: 0.65rem;
+  font-size: 13px;
+  color: #485b73;
+}
+.review-rating label {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 0.4rem 0;
+}
+.review-page input {
+  width: 16px;
+  height: 16px;
+  accent-color: #315dd7;
+  flex-shrink: 0;
+}
+.review-revisit {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 0.4rem 1rem;
+  margin-top: 1rem;
+  font-size: 12px;
+  color: #58697e;
+}
+.review-revisit a {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.3rem;
+  color: #2457a3;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+}
+.review-evidence {
+  border: 0;
+  padding: 0;
+  margin: 1.25rem 0;
+}
+.review-evidence > label {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.85rem;
+  padding: 1.1rem 0;
+  border-top: 1px solid #e1e7ee;
+  cursor: pointer;
+}
+.review-evidence input {
+  margin-top: 0.3rem;
+}
+.review-evidence strong,
+.review-evidence label > span > span {
+  display: block;
+}
+.review-evidence label > span > span {
+  margin-top: 0.5rem;
+  font-size: 13px;
+  line-height: 1.8;
+  color: #57667a;
+}
+.review-result {
+  border-left: 3px solid #3b69d7;
+  background: #f2f6fd;
+  padding: 1.25rem 1.5rem;
+}
+.review-result-ready {
+  border-color: #2e795b;
+  background: #f0f8f3;
+}
+.review-result strong {
+  font-size: 17px;
+}
+.review-result p:last-child {
+  margin-bottom: 0;
+}
+.review-gaps {
+  list-style: none;
+  padding: 0;
+  margin: 1.25rem 0;
+}
+.review-gaps li {
+  display: flex;
+  gap: 0.6rem;
+  justify-content: space-between;
+  border-bottom: 1px solid #e3e8f0;
+  padding: 0.65rem 0;
+}
+.review-gaps a,
+.review-pending-evidence a {
+  color: #2457a3;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+}
+.review-gaps span {
+  color: #65738a;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+.review-pending-evidence {
+  margin: 1.25rem 0;
+}
+.review-result-actions {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 1rem;
+  margin-top: 1.5rem;
+}
+.review-reset {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4rem;
+  padding: 0.6rem 0;
+  border: 0;
+  background: transparent;
+  color: #596b81;
+  font-size: 12px;
+  cursor: pointer;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+}
+@media (max-width: 640px) {
+  .review-question {
+    padding: 1rem;
+  }
+  .review-result {
+    padding: 1rem;
+  }
+  .review-gaps li {
+    flex-direction: column;
+  }
+}

+ 2 - 2
app/task-delivery-demo.tsx

@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
 const states = {
   ready: {
     title: '等待请求',
-    detail: '尚未创建任务。客户端准备提交一次笔记导出请求。',
+    detail: '尚未创建任务。客户端准备提交一次商品导出请求。',
     node: -1,
     deliveries: 0,
     writes: 0,
@@ -157,7 +157,7 @@ export function TaskDeliveryDemo() {
         {!next[state] && <Check size={16} />}
       </div>
       <p className="demo-footnote">
-        本例假设业务结果与去重记录处于同一个数据库事务,并有唯一约束。发送邮件、调用支付等外部副作用需要单独设计幂等协议。队列恢复时间取决于
+        本例假设业务结果与去重记录处于同一个数据库事务,并有唯一约束。发送通知等外部副作用需要单独设计幂等协议。队列恢复时间取决于
         broker、ACK 和 visibility timeout 配置。
       </p>
     </div>

+ 270 - 0
app/teaching-diagrams.css

@@ -0,0 +1,270 @@
+.teaching-diagrams {
+  min-width: 0;
+}
+.teaching-section-heading > span {
+  display: block;
+  margin-bottom: 7px;
+  color: var(--primary);
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+}
+.teaching-section-intro {
+  margin-top: 12px;
+  color: var(--muted-foreground);
+  font-size: 14px;
+}
+.teaching-figure {
+  margin: 24px 0;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  background: var(--card);
+}
+.teaching-caption {
+  padding: 24px 26px 18px;
+}
+.teaching-number {
+  display: block;
+  margin-bottom: 8px;
+  color: #71809a;
+  font-size: 11px;
+  letter-spacing: 0.08em;
+}
+.teaching-caption h3 {
+  font-size: 19px;
+  line-height: 1.55;
+  margin: 0 0 10px;
+}
+.teaching-caption p {
+  color: #5b6980;
+  font-size: 14px;
+  margin: 0;
+  line-height: 1.85;
+}
+.teaching-scroll {
+  margin: 0;
+  max-width: 100%;
+  overflow-x: auto;
+  overscroll-behavior-x: contain;
+  scrollbar-width: thin;
+  padding: 0 12px;
+}
+.teaching-row-guide {
+  list-style: none;
+  margin: 0;
+  padding: 0 26px 18px;
+  font-size: 13px;
+  color: #56667e;
+}
+.teaching-row-guide li {
+  display: flex;
+  gap: 10px;
+  margin-top: 6px;
+}
+.teaching-row-guide span {
+  flex-shrink: 0;
+  color: #2f6bff;
+  font-weight: 600;
+}
+.teaching-scroll:focus-visible {
+  outline: 2px solid var(--primary);
+  outline-offset: -3px;
+}
+.teaching-svg {
+  display: block;
+  width: 100%;
+  min-width: 780px;
+  height: auto;
+  font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
+}
+.teaching-lane {
+  fill: #f6f9ff;
+  stroke: #edf1f8;
+}
+.teaching-lane-secondary {
+  fill: #fafbfc;
+}
+.teaching-row-title {
+  fill: #56667e;
+  font-size: 12px;
+  font-weight: 500;
+}
+.teaching-node > rect:first-child {
+  fill: #fff;
+  stroke: #cbd9ef;
+  stroke-width: 1.25;
+}
+.teaching-node .teaching-node-accent {
+  fill: #4075d6;
+}
+.teaching-node-green > rect:first-child {
+  fill: #f2fbf7;
+  stroke: #aad7c5;
+}
+.teaching-node-green .teaching-node-accent {
+  fill: #287b61;
+}
+.teaching-node-amber > rect:first-child {
+  fill: #fffbf0;
+  stroke: #e7cd8d;
+}
+.teaching-node-amber .teaching-node-accent {
+  fill: #aa7b24;
+}
+.teaching-node-red > rect:first-child {
+  fill: #fff6f4;
+  stroke: #e6b4a9;
+}
+.teaching-node-red .teaching-node-accent {
+  fill: #b4543f;
+}
+.teaching-node-neutral > rect:first-child {
+  fill: #fafbfc;
+  stroke: #d3dae4;
+}
+.teaching-node-title {
+  fill: #253750;
+  font-size: 15px;
+  font-weight: 600;
+}
+.teaching-node-detail {
+  fill: #586a82;
+  font-size: 12px;
+}
+.teaching-edge {
+  fill: none;
+  stroke: #56759d;
+  stroke-width: 1.7;
+  stroke-linejoin: round;
+}
+.teaching-edge-async {
+  stroke: #846521;
+  stroke-dasharray: 6 4;
+}
+.teaching-edge-risk {
+  stroke: #ae523e;
+  stroke-dasharray: 3 3;
+}
+.teaching-arrow-sync {
+  fill: #56759d;
+}
+.teaching-arrow-async {
+  fill: #846521;
+}
+.teaching-arrow-risk {
+  fill: #ae523e;
+}
+.teaching-edge-label {
+  fill: #4d6381;
+  font-size: 11px;
+  paint-order: stroke;
+  stroke: #fff;
+  stroke-width: 5px;
+  stroke-linejoin: round;
+}
+.teaching-edge-label-async {
+  fill: #765a20;
+}
+.teaching-edge-label-risk {
+  fill: #a04433;
+}
+.teaching-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 18px;
+  padding: 0 26px 18px;
+  color: #6a7890;
+  font-size: 11px;
+  line-height: 1.7;
+}
+.teaching-legend > span {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+}
+.teaching-legend i {
+  display: inline-block;
+  width: 22px;
+  border-top: 2px solid #56759d;
+}
+.teaching-legend .teaching-legend-async {
+  border-top: 2px dashed #846521;
+}
+.teaching-legend .teaching-legend-risk {
+  border-top: 2px dotted #ae523e;
+}
+.teaching-explanation {
+  padding: 20px 26px 24px;
+  border-top: 1px solid var(--border);
+}
+.teaching-explanation h4 {
+  color: #2b405d;
+  font-size: 14px;
+  margin: 0 0 12px;
+}
+.teaching-explanation ol {
+  list-style: decimal;
+  margin: 0;
+  padding-left: 23px;
+}
+.teaching-explanation li {
+  padding-left: 4px;
+  margin: 8px 0;
+  color: #4c5c73;
+  font-size: 14px;
+  line-height: 1.95;
+}
+.teaching-explanation li::marker {
+  color: #547dc2;
+  font-weight: 600;
+}
+.teaching-misconception {
+  border-left: 3px solid #d9ba78;
+  background: #fffbf2;
+  padding: 13px 16px;
+  margin: 19px 0 0;
+  color: #6f5c38;
+  font-size: 13px;
+  line-height: 1.9;
+}
+.teaching-misconception strong {
+  display: block;
+  margin-bottom: 3px;
+  color: #896324;
+  font-weight: 600;
+}
+.teaching-source {
+  display: inline-block;
+  margin-top: 15px;
+  color: var(--primary);
+  font-size: 12px;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+}
+@media (max-width: 640px) {
+  .teaching-caption,
+  .teaching-explanation {
+    padding: 18px;
+  }
+  .teaching-caption h3 {
+    font-size: 17px;
+  }
+  .teaching-legend {
+    padding: 0 18px 16px;
+  }
+  .teaching-scroll {
+    padding: 0 6px;
+  }
+}
+@media print {
+  .teaching-scroll {
+    overflow: visible;
+  }
+  .teaching-svg {
+    min-width: 0;
+  }
+  .teaching-figure {
+    break-inside: avoid;
+  }
+}

+ 374 - 0
app/teaching-diagrams.tsx

@@ -0,0 +1,374 @@
+'use client';
+
+import { useId } from 'react';
+import {
+  getLessonDiagrams,
+  getModuleDiagrams,
+  type DiagramEdge,
+  type DiagramNode,
+  type DiagramPoint,
+  type TeachingDiagram,
+} from './diagram-catalog';
+import './teaching-diagrams.css';
+
+export { hasLessonDiagrams } from './diagram-catalog';
+
+const NODE_WIDTH = 210;
+const NODE_HEIGHT = 104;
+
+// Wide glyphs need more space than Latin glyphs. Never clip educational text.
+function linesFor(text: string, maxUnits: number) {
+  const lines: string[] = [];
+  let line = '';
+  let units = 0;
+  for (const character of text) {
+    const width = /[\u0020-\u007e]/.test(character) ? 1 : 2;
+    if (units + width > maxUnits && line) {
+      lines.push(line.trim());
+      line = '';
+      units = 0;
+    }
+    line += character;
+    units += width;
+  }
+  if (line) lines.push(line.trim());
+  return lines;
+}
+
+function port(
+  node: DiagramNode,
+  side: NonNullable<DiagramEdge['fromPort']>,
+): DiagramPoint {
+  if (side === 'left') return [node.x, node.y + NODE_HEIGHT / 2];
+  if (side === 'right') return [node.x + NODE_WIDTH, node.y + NODE_HEIGHT / 2];
+  if (side === 'top') return [node.x + NODE_WIDTH / 2, node.y];
+  return [node.x + NODE_WIDTH / 2, node.y + NODE_HEIGHT];
+}
+
+function edgeGeometry(edge: DiagramEdge, nodes: DiagramNode[]) {
+  const source = nodes.find((node) => node.id === edge.from)!;
+  const target = nodes.find((node) => node.id === edge.to)!;
+  const sameRow = source.y === target.y;
+  const sameColumn = source.x === target.x;
+  const right = target.x > source.x;
+  const down = target.y > source.y;
+  const fromSide =
+    edge.fromPort ??
+    (sameRow ? (right ? 'right' : 'left') : down ? 'bottom' : 'top');
+  const toSide =
+    edge.toPort ??
+    (sameRow ? (right ? 'left' : 'right') : down ? 'top' : 'bottom');
+  const start = port(source, fromSide);
+  const end = port(target, toSide);
+  const middleY = (start[1] + end[1]) / 2;
+  const via =
+    edge.via ??
+    (sameRow || sameColumn
+      ? []
+      : ([
+          [start[0], middleY],
+          [end[0], middleY],
+        ] as DiagramPoint[]));
+  const points: DiagramPoint[] = [start, ...via, end];
+  const path = points
+    .map(([x, y], index) => `${index ? 'L' : 'M'} ${x} ${y}`)
+    .join(' ');
+  const labelAt =
+    edge.labelAt ??
+    (sameRow || sameColumn
+      ? ([(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as DiagramPoint)
+      : ([(start[0] + end[0]) / 2, middleY] as DiagramPoint));
+  const vertical = sameColumn && !edge.via;
+  return { path, labelAt, vertical };
+}
+
+function DiagramFigure({
+  diagram,
+  index,
+}: {
+  diagram: TeachingDiagram;
+  index: number;
+}) {
+  const reactId = useId().replace(/:/g, '');
+  const instanceId = `teaching-${diagram.id}-${reactId}`;
+  const titleId = `${instanceId}-title`;
+  const descriptionId = `${instanceId}-description`;
+  const height = diagram.rows.length * 185 + 60;
+  return (
+    <figure
+      className="teaching-figure"
+      aria-labelledby={`${instanceId}-heading`}
+    >
+      <figcaption className="teaching-caption">
+        <span className="teaching-number">
+          图解 {String(index + 1).padStart(2, '0')}
+        </span>
+        <h3 id={`${instanceId}-heading`}>{diagram.title}</h3>
+        <p>{diagram.summary}</p>
+      </figcaption>
+      <ol className="teaching-row-guide" aria-label="图中区域说明">
+        {diagram.rows.map((label, row) => (
+          <li key={label}>
+            <span>区域 {row + 1}</span>
+            {label}
+          </li>
+        ))}
+      </ol>
+      {/* oxlint-disable jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to focus and scroll the full-size diagram. */}
+      <section
+        className="teaching-scroll"
+        tabIndex={0}
+        aria-label={`${diagram.title},窄屏可横向滚动;下方有完整文字说明`}
+      >
+        {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- Inline SVG needs image semantics and native title/desc labels. */}
+        <svg
+          className="teaching-svg"
+          viewBox={`0 0 900 ${height}`}
+          role="img"
+          aria-labelledby={`${titleId} ${descriptionId}`}
+        >
+          {/* oxlint-enable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/prefer-tag-over-role */}
+          <title id={titleId}>{diagram.title}</title>
+          <desc id={descriptionId}>
+            {diagram.summary}{' '}
+            {diagram.edges
+              .map(
+                (edge) =>
+                  `${diagram.nodes.find((node) => node.id === edge.from)?.title},${edge.label},到${diagram.nodes.find((node) => node.id === edge.to)?.title}。`,
+              )
+              .join(' ')}{' '}
+            完整步骤与边界说明见图下文字。
+          </desc>
+          <defs>
+            {(['sync', 'async', 'risk'] as const).map((kind) => (
+              <marker
+                key={kind}
+                id={`${instanceId}-${kind}`}
+                viewBox="0 0 10 10"
+                refX="9"
+                refY="5"
+                markerWidth="7"
+                markerHeight="7"
+                orient="auto-start-reverse"
+              >
+                <path
+                  d="M 1 1 L 9 5 L 1 9 Z"
+                  className={`teaching-arrow-${kind}`}
+                />
+              </marker>
+            ))}
+          </defs>
+          {diagram.rows.map((label, row) => (
+            <g key={label}>
+              <rect
+                x="12"
+                y={row * 185 + 7}
+                width="876"
+                height="173"
+                rx="10"
+                className={
+                  row % 2
+                    ? 'teaching-lane teaching-lane-secondary'
+                    : 'teaching-lane'
+                }
+              />
+              <text x="30" y={row * 185 + 30} className="teaching-row-title">
+                区域 {row + 1}
+              </text>
+            </g>
+          ))}
+          {diagram.edges.map((edge, edgeIndex) => {
+            const geometry = edgeGeometry(edge, diagram.nodes);
+            return (
+              <path
+                key={`path-${edgeIndex}`}
+                d={geometry.path}
+                className={`teaching-edge teaching-edge-${edge.kind}`}
+                markerEnd={`url(#${instanceId}-${edge.kind})`}
+              />
+            );
+          })}
+          {diagram.nodes.map((node) => {
+            const titleLines = linesFor(node.title, 24);
+            const detailLines = linesFor(node.detail, 29);
+            return (
+              <g
+                key={node.id}
+                className={`teaching-node teaching-node-${node.tone}`}
+              >
+                <rect
+                  x={node.x}
+                  y={node.y}
+                  width={NODE_WIDTH}
+                  height={NODE_HEIGHT}
+                  rx="9"
+                />
+                <rect
+                  x={node.x}
+                  y={node.y + 15}
+                  width="3"
+                  height={NODE_HEIGHT - 30}
+                  rx="1.5"
+                  className="teaching-node-accent"
+                />
+                <text
+                  x={node.x + 16}
+                  y={node.y + 26}
+                  className="teaching-node-title"
+                >
+                  {titleLines.map((line, lineIndex) => (
+                    <tspan
+                      key={lineIndex}
+                      x={node.x + 16}
+                      dy={lineIndex ? 19 : 0}
+                    >
+                      {line}
+                    </tspan>
+                  ))}
+                </text>
+                <text
+                  x={node.x + 16}
+                  y={node.y + 26 + titleLines.length * 19 + 5}
+                  className="teaching-node-detail"
+                >
+                  {detailLines.map((line, lineIndex) => (
+                    <tspan
+                      key={lineIndex}
+                      x={node.x + 16}
+                      dy={lineIndex ? 16 : 0}
+                    >
+                      {line}
+                    </tspan>
+                  ))}
+                </text>
+              </g>
+            );
+          })}
+          {diagram.edges.map((edge, edgeIndex) => {
+            const { labelAt, vertical } = edgeGeometry(edge, diagram.nodes);
+            const labelLines = linesFor(edge.label, vertical ? 20 : 17);
+            const x = labelAt[0] + (vertical ? 10 : 0);
+            const y =
+              labelAt[1] -
+              (vertical
+                ? (labelLines.length - 1) * 7
+                : 10 + (labelLines.length - 1) * 14);
+            return (
+              <text
+                key={`label-${edgeIndex}`}
+                x={x}
+                y={y}
+                textAnchor={vertical ? 'start' : 'middle'}
+                className={`teaching-edge-label teaching-edge-label-${edge.kind}`}
+              >
+                {labelLines.map((line, lineIndex) => (
+                  <tspan key={lineIndex} x={x} dy={lineIndex ? 14 : 0}>
+                    {line}
+                  </tspan>
+                ))}
+              </text>
+            );
+          })}
+        </svg>
+      </section>
+      <div className="teaching-legend" aria-label="图例">
+        <span>
+          <i className="teaching-legend-sync" aria-hidden="true" />
+          调用 / 数据或状态流向
+        </span>
+        {diagram.edges.some((edge) => edge.kind === 'async') && (
+          <span>
+            <i className="teaching-legend-async" aria-hidden="true" />
+            异步交接 / 后续处理
+          </span>
+        )}
+        {diagram.edges.some((edge) => edge.kind === 'risk') && (
+          <span>
+            <i className="teaching-legend-risk" aria-hidden="true" />
+            失败 / 恢复分支
+          </span>
+        )}
+        <span>沿箭头与编号阅读,颜色仅辅助区分</span>
+      </div>
+      <div className="teaching-explanation">
+        <h4>顺着图走一遍</h4>
+        <ol>
+          {diagram.steps.map((step, stepIndex) => (
+            <li key={stepIndex}>{step}</li>
+          ))}
+        </ol>
+        <p className="teaching-misconception">
+          <strong>容易误解</strong>
+          {diagram.misconception}
+        </p>
+        {diagram.source && (
+          <a
+            className="teaching-source"
+            href={diagram.source.url}
+            target="_blank"
+            rel="noreferrer"
+          >
+            进一步核对:{diagram.source.label}
+            <span aria-hidden="true"> ↗</span>
+          </a>
+        )}
+      </div>
+    </figure>
+  );
+}
+
+export function ModuleDiagrams({ module }: { module: string }) {
+  const diagrams = getModuleDiagrams(module);
+  if (!diagrams.length) return null;
+  return (
+    <section
+      id="module-diagrams"
+      className="teaching-diagrams"
+      aria-labelledby="module-diagrams-heading"
+    >
+      <div className="teaching-section-heading">
+        <span>先建立画面</span>
+        <h2 id="module-diagrams-heading">这个模块怎样与其他组件协作</h2>
+      </div>
+      <p className="teaching-section-intro">
+        先认清谁接收请求、谁保存数据、谁负责执行,再进入每节课理解细节。相关课时中还有针对具体机制的图解。
+      </p>
+      {diagrams.map((diagram, index) => (
+        <DiagramFigure key={diagram.id} diagram={diagram} index={index} />
+      ))}
+    </section>
+  );
+}
+
+export function LessonDiagrams({
+  module,
+  lesson,
+}: {
+  module: string;
+  lesson: string;
+}) {
+  const diagrams = getLessonDiagrams(module, lesson);
+  if (!diagrams.length) return null;
+  return (
+    <section
+      id="diagrams"
+      className="teaching-diagrams"
+      aria-labelledby="lesson-diagrams-heading"
+    >
+      <div className="teaching-section-heading">
+        <span>做过之后,再看整体</span>
+        <h2 id="lesson-diagrams-heading">
+          {module === 'database' ? '回看数据设计与事务' : '把刚才的过程连起来'}
+        </h2>
+      </div>
+      <p className="teaching-section-intro">
+        {module === 'database'
+          ? '结合表关系与事务流程,理解数据怎样组织、约束在哪里生效,以及修改何时提交。'
+          : '箭头表示信息或控制如何传递;下方步骤说明等待、交接和失败时分别由谁负责。'}
+      </p>
+      {diagrams.map((diagram, index) => (
+        <DiagramFigure key={diagram.id} diagram={diagram} index={index} />
+      ))}
+    </section>
+  );
+}

+ 34 - 32
examples/README.md

@@ -1,6 +1,6 @@
-# API Lab · FastAPI 笔记服务
+# API Lab · FastAPI 商品服务
 
-这份代码配合「API 服务开发」模块。用同一个小服务练习请求参数、REST CRUD、错误响应、依赖、异步等待、SSE 与 WebSocket。代码直接运行,不需要模型账户或模型密钥。
+这份代码配合「API 服务开发」模块,使用陶瓷杯、帆布袋等商品目录作为贯穿案例。业务只涉及名称、描述、价格与上架状态,后续再用分类、评价和商品清单导出讲解数据库与任务技术。用同一个小服务练习请求参数、REST CRUD、错误响应、依赖、异步等待、SSE 与 WebSocket。代码直接运行,不需要模型账户或模型密钥。
 
 这是各课核心功能的参考实现。课后扩展由学生按课件添加,例如第 06 课的 offset 分页、第 13 课的 POST /stream,以及 /private/status。`PromptIn`、`encode_sse()` 与 `token_events()` 已提供,第 13 课可直接复用。代码不预置全部练习答案。
 
@@ -44,20 +44,20 @@ python -m uvicorn main:app --reload
 - `http://127.0.0.1:8000/docs`:展开端点,点击 **Try it out → Execute**,观察 Request URL、Response code 和 Response body。
 - `http://127.0.0.1:8000/openapi.json`:查看接口描述。
 
-`main:app` 表示导入 `main.py` 中的 `app` 对象。`--reload` 在代码改动后重启进程,因此笔记会清空。按 `Ctrl+C` 停止服务。
+`main:app` 表示导入 `main.py` 中的 `app` 对象。`--reload` 在代码改动后重启进程,因此商品会清空。按 `Ctrl+C` 停止服务。
 
 ## 2. 跑一遍 CRUD
 
 在 `/docs` 按顺序执行:
 
-1. `POST /notes` 提交 `{"title":"第一篇笔记"}`,得到 **201**,记录返回的 `id` 与响应头 `Location: /notes/{id}`。`content` 默认空字符串,`pinned` 默认 `false`。
-2. `GET /notes` 得到 JSON 数组,默认最多 10 条;填写 `q` 过滤标题或正文,填写 `pinned=true` 或 `pinned=false` 筛选置顶状态,填写 `limit` 限制返回条数。先筛选再限制条数,不传 `pinned` 时保留两种状态。
-3. `GET /notes/{note_id}` 用刚才的 ID 读取笔记。
-4. `PUT /notes/{note_id}` 提交 `{"title":"修改后的笔记","content":"完整替换","pinned":true}`。三个可修改字段都必须出现,少一个得到 **422**。
-5. `DELETE /notes/{note_id}` 得到 **204** 和空响应体。此时不要调用前端的 `response.json()`。
-6. 再读同一个 ID,得到 **404**;标题提交空字符串、纯空白或超过 120 个字符,得到 **422**。
+1. `POST /products` 提交 `{"name":"陶瓷杯"}`,得到 **201**,记录返回的 `id` 与响应头 `Location: /products/{id}`。`description` 默认空字符串,`is_active` 默认 `false`,`price_cents` 默认 `0`(免费教学样品)。
+2. `GET /products` 得到 JSON 数组,默认最多 10 条;填写 `q` 过滤名称或描述,填写 `is_active=true` 或 `is_active=false` 筛选上架状态,填写 `limit` 限制返回条数。先筛选再限制条数,不传 `is_active` 时保留两种状态。
+3. `GET /products/{product_id}` 用刚才的 ID 读取商品。
+4. `PUT /products/{product_id}` 提交 `{"name":"蓝色陶瓷杯","description":"350ml","is_active":true,"price_cents":1990}`。四个可修改字段都必须出现,少一个得到 **422**。
+5. `DELETE /products/{product_id}` 得到 **204** 和空响应体。此时不要调用前端的 `response.json()`。
+6. 再读同一个 ID,得到 **404**;名称提交空字符串、纯空白或超过 120 个字符,得到 **422**。
 
-标题、正文会去掉首尾空白。`pinned` 是布尔类型,示例使用 JSON 的 `true` / `false`;Pydantic 默认还允许部分类型转换,不等同于严格模式。未声明的请求体字段会返回 422。
+名称、描述会去掉首尾空白;描述最多 16000 字符,与后续数据库 TEXT 的字符预算一致。`is_active` 是布尔类型,示例使用 JSON 的 `true` / `false`;Pydantic 默认还允许部分类型转换,不等同于严格模式。价格 `price_cents` 则明确使用严格整数分:1990 表示 19.90 元,合法范围为 0~2147483647,与后续 MySQL INT 一致;小数、字符串、布尔值和负数都会返回 422。未声明的请求体字段会返回 422。
 
 ## 3. 浏览器联调
 
@@ -69,9 +69,9 @@ python -m http.server 5500
 
 打开 `http://localhost:5500/web-client.html`。不要双击 HTML 使用 `file://`。页面有三个可以直接操作的实验:
 
-- 创建笔记:`fetch()` 发送 POST JSON,输出状态码与响应体。
-- SSE:通过 `EventSource` 收到三个 `token` 事件和一个 `done` 事件;完整文本逐段显示。
-- WebSocket:连接后发送文本并收到 echo,发送文本 `ping` 收到 `pong`,然后断开并重连。
+- 创建商品:`fetch()` 发送 POST JSON,输出状态码与响应体。
+- SSE:模拟商品清单导出进度,通过 `EventSource` 收到三个 `token` 事件和一个 `done` 事件;完整文本逐段显示。
+- WebSocket:以商品咨询为背景,连接后发送问题文本并收到 echo,发送文本 `ping` 收到 `pong`,然后断开并重连。
 
 打开浏览器开发者工具的 **Network** 面板。POST JSON 的跨域请求会触发 OPTIONS 预检;SSE 在连接内连续收到事件;WebSocket 在 Messages / Frames 中显示双向消息。不同浏览器的面板名称略有差异。
 
@@ -81,31 +81,33 @@ python -m http.server 5500
 
 ## 4. SSE 的实际内容
 
+本实验模拟商品清单导出进度,只传输进度文本,不生成实际文件。`prompt` 是清单名称;`/generate` 和 `/stream` 分别演示一次返回和逐段返回。WebSocket 则只回显商品咨询文本,不实现客服回复逻辑。
+
 先启动 API。在另一终端执行以下命令,观察每个事件约隔 0.4 秒到达:
 
 Windows PowerShell:
 
 ```powershell
-curl.exe -N 'http://127.0.0.1:8000/stream?prompt=hello'
+curl.exe -N 'http://127.0.0.1:8000/stream?prompt=catalog'
 ```
 
 macOS / Linux:
 
 ```bash
-curl -N 'http://127.0.0.1:8000/stream?prompt=hello'
+curl -N 'http://127.0.0.1:8000/stream?prompt=catalog'
 ```
 
 协议内容如下。每个事件末尾的空行负责划分事件边界。
 
 ```text
 event: token
-data: {"text": "收到:"}
+data: {"text": "准备导出商品清单:"}
 
 event: token
-data: {"text": "hello"}
+data: {"text": "catalog"}
 
 event: token
-data: {"text": "。处理完成。"}
+data: {"text": "。导出进度演示完成。"}
 
 event: done
 data: {}
@@ -116,7 +118,7 @@ data: {}
 
 对照 `POST /generate`:它也模拟三个 0.4 秒处理步骤,但等约 1.2 秒后才一次返回完整 JSON。SSE 每完成一个步骤就发送一次。流正常完成或中途关闭时,`token_events()` 的 `finally` 会在服务端终端打印 `stream closed`。
 
-`EventSource` 默认使用 GET,不能直接提交 JSON 请求体或任意自定义请求头。本例使用查询参数;需要 POST 请求体时可按课程扩展为 `fetch()` 与 `ReadableStream`。页面在收到 `done`、发生错误、手动停止、再次开始或离开页面时关闭旧连接,避免默认重连再次生成内容。
+`EventSource` 默认使用 GET,不能直接提交 JSON 请求体或任意自定义请求头。本例使用查询参数;需要 POST 请求体时可按课程扩展为 `fetch()` 与 `ReadableStream`。页面在收到 `done`、发生错误、手动停止、再次开始或离开页面时关闭旧连接,避免默认重连再次发送导出进度。
 
 服务检查客户端是否断开。`Cache-Control: no-cache` 减少缓存干扰;`X-Accel-Buffering: no` 用于支持它的代理。真实线上流式到达仍取决于代理与客户端配置,不能只靠这个响应头保证。
 
@@ -128,7 +130,7 @@ data: {}
 - 设置后,漏传或传错 `X-API-Key`:返回 **401**。
 - 设置为 `classroom-key` 后传入同样的请求头:返回 **200**。
 
-`classroom-key` 只是课堂示例值。只有 `/private` 受这个演示依赖保护,`/notes`、`/stream` 和 `/ws` 没有身份验证。
+`classroom-key` 只是课堂示例值。只有 `/private` 受这个演示依赖保护,`/products`、`/stream` 和 `/ws` 没有身份验证。
 
 `GET /slow?delay=1` 使用 `await asyncio.sleep()` 模拟可等待的 I/O。合法等待时间是 0~5 秒。服务启动后运行:
 
@@ -147,22 +149,22 @@ python concurrency_client.py
 | 方法与路径 | 输入 | 成功响应 | 练习目标 |
 | --- | --- | --- | --- |
 | GET `/health` | 无 | 200 `{"status":"ok"}` | 路由与启动 |
-| GET `/notes` | 可选 `q`、`pinned`;`limit` 默认 10,范围 1~100 | 200 笔记数组 | 查询参数 |
-| POST `/notes` | `title`;可选 `content`、`pinned` | 201 笔记对象;`Location` 指向新资源 | 请求体与默认值 |
-| GET `/notes/{note_id}` | 正整数 ID | 200 笔记对象 | 路径参数、404 |
-| PUT `/notes/{note_id}` | `title`、`content`、`pinned` 都必填 | 200 笔记对象 | 完整替换 |
-| DELETE `/notes/{note_id}` | 正整数 ID | 204 空响应体 | 删除语义 |
+| GET `/products` | 可选 `q`、`is_active`;`limit` 默认 10,范围 1~100 | 200 商品数组 | 查询参数 |
+| POST `/products` | `name`;可选 `description`、`is_active`、`price_cents` | 201 商品对象;`Location` 指向新资源 | 请求体与默认值 |
+| GET `/products/{product_id}` | 正整数 ID | 200 商品对象 | 路径参数、404 |
+| PUT `/products/{product_id}` | `name`、`description`、`is_active`、`price_cents` 都必填 | 200 商品对象 | 完整替换 |
+| DELETE `/products/{product_id}` | 正整数 ID | 204 空响应体 | 删除语义 |
 | GET `/private` | 请求头 `X-API-Key` | 200 `{"message":"API key accepted"}` | 依赖与环境变量 |
 | GET `/slow` | `delay` 默认 1,范围 0~5 | 200 `{"delay":1,"status":"done"}` | 异步等待 |
 | GET `/upstream-demo` | `delay` 默认 1,范围 0~5 | 200 `{"upstream":{"delay":1,"status":"done"}}` | HTTPX 生命周期、502/504 |
-| POST `/generate` | `{"prompt":"hello"}`,1~200 字符 | 200 `{"text":"收到:hello。处理完成。"}` | 一次性响应 |
-| GET `/stream` | `prompt` 默认 hello,1~200 字符 | 200 `text/event-stream` | 有限 SSE 流 |
+| POST `/generate` | `{"prompt":"catalog"}`,1~200 字符 | 200 `{"text":"准备导出商品清单:catalog。导出进度演示完成。"}` | 一次性响应 |
+| GET `/stream` | `prompt` 默认 catalog,1~200 字符 | 200 `text/event-stream` | 有限 SSE 流 |
 | WS `/ws` | 文本;文本 `ping` 是特殊约定 | echo 或 pong JSON 消息 | 同一连接上的双向通信 |
 
 HTTP 异常与请求校验错误统一为:
 
 ```json
-{"error":{"code":"http_error","message":"Note not found","details":[]}}
+{"error":{"code":"http_error","message":"Product not found","details":[]}}
 ```
 
 校验失败时,`code` 为 `validation_error`,`details` 包含 `loc`、`msg`、`type`。这个教学处理器覆盖 HTTPException 与 RequestValidationError;未处理的程序异常、CORS 预检拒绝和 WebSocket 消息不属于该 JSON 契约。生产环境的日志、鉴权与错误追踪在后续模块补充。
@@ -174,9 +176,9 @@ python -m pytest -q
 python -m pip check
 ```
 
-测试覆盖 CRUD 全流程与 Location、PUT 必填字段、404/422、标题限制、q/pinned 组合过滤与默认 limit、环境变量与请求头、SSE 帧边界、中文与换行内容、WebSocket echo/ping/重连、CORS 预检和健康端点。HTTPX 上游成功、超时、HTTP 错误、连接失败使用 MockTransport 验证,不访问公网;同时检查 lifespan 关闭客户端。`TestClient` 的 SSE 测试检查协议格式与完整内容,不用于判断真实网络的首段到达时间。
+测试覆盖 CRUD 全流程与 Location、PUT 必填字段、404/422、名称与描述长度、价格整数分边界与非法写入不改变数据、q/is_active 组合过滤与默认 limit、环境变量与请求头、SSE 帧边界、中文与换行内容、WebSocket echo/ping/重连、CORS 预检和健康端点。HTTPX 上游成功、超时、HTTP 错误、连接失败使用 MockTransport 验证,不访问公网;同时检查 lifespan 关闭客户端。`TestClient` 的 SSE 测试检查协议格式与完整内容,不用于判断真实网络的首段到达时间。
 
-实际验证结果记录在 `VERIFICATION.md`。示例的测试会重置内存笔记,适合独立测试进程,不要将测试代码作为线上操作入口。
+实际验证结果记录在 `VERIFICATION.md`。示例的测试会重置内存商品,适合独立测试进程,不要将测试代码作为线上操作入口。
 
 ## 8. 文件与使用边界
 
@@ -184,9 +186,9 @@ python -m pip check
 - `web-client.html`:不需要构建工具的本地浏览器客户端。
 - `concurrency_client.py`:四个并发请求的实测脚本。
 - `test_main.py`:可执行验收。
-- `requirements.txt`:已验证的固定依赖。
+- `requirements.txt`:沿用的基线依赖锁;本次实际验收环境见 `VERIFICATION.md`。
 
-所有笔记保存在当前 Python 进程的内存中,重启清空、多个 worker 彼此不共享。请按示例运行单个开发进程。这里没有用户隔离、持久化、线上密钥管理、限流或任务队列;课程后续再接入数据库及部署能力。
+所有商品保存在当前 Python 进程的内存中,重启清空、多个 worker 彼此不共享。请按示例运行单个开发进程。这里没有用户隔离、持久化、线上密钥管理、限流或任务队列;课程后续再接入数据库及部署能力。
 
 ## 官方参考
 

+ 29 - 25
examples/VERIFICATION.md

@@ -1,51 +1,55 @@
-# 验证记录
+# 商品服务验证记录
 
-验证日期:2026-09-10。系统:Windows。Python:3.12.6。使用独立虚拟环境安装 `requirements.txt`,未修改全局 Python 环境。
+验证日期:2026-09-10。系统:Windows。Python:3.12.6。本次使用机器现有 Python 环境,未安装或修改全局依赖。`requirements.txt` 保留此前锁定版本;本次未重新安装该锁定环境,实际使用版本见下表。
 
 ## 自动化验收
 
+在仓库根目录运行:
+
 ```text
-python -m pytest -q
-30 passed, 1 warning in 16.04s
+python -m pytest -q examples/test_main.py
+41 passed in 21.89s
 
 python -m pip check
 No broken requirements found.
 ```
 
-唯一警告来自 Starlette TestClient 对 AnyIO `BlockingPortal` 旧别名的引用,是依赖的弃用提示;本次没有测试失败。代码没有屏蔽该提示。
+覆盖健康检查与 OpenAPI、商品 CRUD 与 Location、q/is_active 组合筛选、默认 10 条限制、名称与查询参数、PUT 四个字段必填、统一 404/422、请求头与环境变量、CORS、SSE 帧和特殊字符、WebSocket echo/ping/重连。
+
+价格专门验证:省略时默认为 0;0、1990、2147483647 保持整数分;负数、小数、字符串、布尔值、null 与超出 INT 上限的值均返回 422。非法创建或替换后原商品保持不变。描述验证 16000 字符边界,过长描述不会写入。
 
-覆盖项:健康检查和 OpenAPI、CRUD 生命周期与 Location 响应头、q/pinned 组合筛选与默认 10 条限制、标题与查询参数验证、PUT 完整替换、统一 404/422 结构、请求头与环境变量、401 认证挑战响应头、SSE 帧及特殊字符、WebSocket echo/ping/重连、CORS 预检。
+HTTPX 使用 MockTransport 验证上游成功、读取超时 504、HTTP 失败 502、连接失败 502,并检查 lifespan 关闭共享客户端。上游故障测试不访问网络。
 
-HTTPX 使用 MockTransport 验证上游成功、读取超时映射 504、非成功 HTTP 状态映射 502、连接失败映射 502;另测 lifespan 创建并关闭共享客户端。这些上游测试不访问网络。
+API 入门与 API + 数据库课件中的 4 组 ProductCreate 模型也已实际执行价格边界验证,涵盖完整替换缺失价格、PATCH 显式 null 拒绝。此 API 验证记录不覆盖数据库结构与集成实验。数据库商品表与分类表的新建结构和约束已另行通过 MySQL 8.4.11 验证,范围见根 README;ORM CRUD、迁移和故障演练未在本次运行,代码语法检查不能替代这些集成验证。
 
 ## 真实网络验证
 
-另外启动真实 Uvicorn 子进程,通过 localhost TCP 连接调用服务。验证结束后已关闭该子进程。
+另外启动隐藏的 Uvicorn 子进程,绑定 localhost 的独立临时端口,通过真实 TCP 和 WebSocket 调用。验证结束后已关闭子进程。
 
 | 核验 | 实测结果 |
 | --- | --- |
-| HTTP 健康检查、文档、创建 / 读取 / 删除、Location、pinned 筛选 | 通过 |
+| 健康检查、文档、商品创建 / 读取 / 替换 / 删除、Location、价格、is_active 筛选 | 通过 |
 | `/private` 正确密钥与漏传密钥 | 200 / 401 |
-| `/generate` 完整结果到达 | 请求开始后 1.218 秒 |
-| 三个 SSE token 到达时间 | 请求开始后 0.411 / 0.813 / 1.199 秒 |
-| 完整文本与 done 事件 | `收到:hello。处理完成。`,通过 |
-| 客户端中途关流后再次健康检查 | 200 |
-| 真实 WebSocket echo、ping、关闭连接 | 通过 |
-| 同时请求四个 `/slow?delay=1` | 全部 200,总耗时 1.054 秒 |
+| `/generate` 完整结果到达 | 请求开始后 1.216 秒 |
+| 三个 SSE token 到达时间 | 请求开始后 0.418 / 0.821 / 1.225 秒 |
+| 完整文本与 done 事件 | `准备导出商品清单:catalog。导出进度演示完成。`,通过 |
+| 客户端中途关流后健康检查 | 200 |
+| 商品咨询文本的 WebSocket echo、ping、关闭连接 | 通过 |
+| 同时请求四个 `/slow?delay=1` | 全部 200,总耗时 1.019 秒 |
 
-上面的耗时是这次本机实测值,不是性能保证。SSE 到达时间来自真实 HTTP 流,未使用 TestClient 测量。
+耗时是本次本机实测值,不是性能保证。SSE 到达时间来自真实 HTTP 流,未使用 TestClient 测量。导出接口只模拟进度文本,未生成实际清单文件;咨询接口只回显消息。
 
-## 已验证的主要版本
+## 本次实际使用版本
 
 | 依赖 | 版本 |
 | --- | --- |
-| FastAPI | 0.141.1 |
-| Pydantic | 2.13.5 |
-| Starlette | 0.52.1 |
-| Uvicorn | 0.52.4 |
-| websockets | 15.0.1 |
+| FastAPI | 0.115.0 |
+| Pydantic | 2.10.2 |
+| Starlette | 0.38.6 |
+| Uvicorn | 0.32.1 |
+| websockets | 16.1.1 |
 | HTTPX | 0.28.1 |
-| pytest | 9.1.1 |
-| AnyIO | 4.15.1 |
+| pytest | 8.4.2 |
+| AnyIO | 4.6.2.post1 |
 
-所有直接与间接依赖的精确版本见 `requirements.txt`。
+这些版本与 `requirements.txt` 的此前基线锁定版本不同;本记录只声明上面实际运行环境的结果。

+ 52 - 49
examples/main.py

@@ -1,4 +1,4 @@
-"""API 服务开发课程:同一个内存笔记服务,练习 HTTP、SSE 与 WebSocket。"""
+"""API 服务开发课程:同一个内存商品服务,练习 HTTP、SSE 与 WebSocket。"""
 
 import asyncio
 import json
@@ -44,7 +44,7 @@ async def lifespan(app: FastAPI):
 
 
 app = FastAPI(
-    title="API Lab · 笔记服务",
+    title="API Lab · 商品服务",
     version="1.0.0",
     lifespan=lifespan,
     responses={422: {"model": ErrorResponse, "description": "请求参数校验失败"}},
@@ -58,29 +58,31 @@ app.add_middleware(
 )
 
 # 教学用内存存储:重启后丢失;单进程使用,不能用多个 worker 共享数据。
-app.state.notes = {}
+app.state.products = {}
 app.state.next_id = 1
 
 
-class NoteCreate(BaseModel):
+class ProductCreate(BaseModel):
     model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
 
-    title: str = Field(min_length=1, max_length=120)
-    content: str = ""
-    pinned: bool = False
+    name: str = Field(min_length=1, max_length=120)
+    description: str = Field(default="", max_length=16000)
+    is_active: bool = False
+    price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)
 
 
-class NoteReplace(BaseModel):
-    """PUT 完整替换:三个可修改字段都必须提交。"""
+class ProductReplace(BaseModel):
+    """PUT 完整替换:四个可修改字段都必须提交。"""
 
     model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
 
-    title: str = Field(min_length=1, max_length=120)
-    content: str
-    pinned: bool
+    name: str = Field(min_length=1, max_length=120)
+    description: str = Field(max_length=16000)
+    is_active: bool
+    price_cents: int = Field(ge=0, le=2147483647, strict=True)
 
 
-class NoteOut(NoteCreate):
+class ProductOut(ProductCreate):
     id: int
 
 
@@ -112,11 +114,11 @@ async def validation_error_handler(request: Request, exc: RequestValidationError
     )
 
 
-def find_note(note_id: int) -> NoteOut:
-    note = app.state.notes.get(note_id)
-    if note is None:
-        raise HTTPException(status_code=404, detail="Note not found")
-    return note
+def find_product(product_id: int) -> ProductOut:
+    product = app.state.products.get(product_id)
+    if product is None:
+        raise HTTPException(status_code=404, detail="Product not found")
+    return product
 
 
 @app.get("/health", tags=["起步"])
@@ -124,47 +126,47 @@ async def health():
     return {"status": "ok"}
 
 
-@app.get("/notes", response_model=list[NoteOut], tags=["笔记 CRUD"])
-async def list_notes(
+@app.get("/products", response_model=list[ProductOut], tags=["商品 CRUD"])
+async def list_products(
     q: Annotated[str | None, Query(max_length=120)] = None,
     limit: Annotated[int, Query(ge=1, le=100)] = 10,
-    pinned: bool | None = None,
+    is_active: bool | None = None,
 ):
-    notes = list(app.state.notes.values())
+    products = list(app.state.products.values())
     if q:
         query = q.casefold()
-        notes = [note for note in notes if query in note.title.casefold() or query in note.content.casefold()]
-    if pinned is not None:
-        notes = [note for note in notes if note.pinned == pinned]
-    return notes[:limit]
+        products = [product for product in products if query in product.name.casefold() or query in product.description.casefold()]
+    if is_active is not None:
+        products = [product for product in products if product.is_active == is_active]
+    return products[:limit]
 
 
-@app.post("/notes", response_model=NoteOut, status_code=201, tags=["笔记 CRUD"])
-async def create_note(body: NoteCreate, response: Response):
-    note = NoteOut(id=app.state.next_id, **body.model_dump())
-    app.state.notes[note.id] = note
+@app.post("/products", response_model=ProductOut, status_code=201, tags=["商品 CRUD"])
+async def create_product(body: ProductCreate, response: Response):
+    product = ProductOut(id=app.state.next_id, **body.model_dump())
+    app.state.products[product.id] = product
     app.state.next_id += 1
-    response.headers["Location"] = f"/notes/{note.id}"
-    return note
+    response.headers["Location"] = f"/products/{product.id}"
+    return product
 
 
-@app.get("/notes/{note_id}", response_model=NoteOut, responses={404: {"model": ErrorResponse}}, tags=["笔记 CRUD"])
-async def get_note(note_id: Annotated[int, Path(ge=1)]):
-    return find_note(note_id)
+@app.get("/products/{product_id}", response_model=ProductOut, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
+async def get_product(product_id: Annotated[int, Path(ge=1)]):
+    return find_product(product_id)
 
 
-@app.put("/notes/{note_id}", response_model=NoteOut, responses={404: {"model": ErrorResponse}}, tags=["笔记 CRUD"])
-async def replace_note(note_id: Annotated[int, Path(ge=1)], body: NoteReplace):
-    find_note(note_id)
-    note = NoteOut(id=note_id, **body.model_dump())
-    app.state.notes[note_id] = note
-    return note
+@app.put("/products/{product_id}", response_model=ProductOut, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
+async def replace_product(product_id: Annotated[int, Path(ge=1)], body: ProductReplace):
+    find_product(product_id)
+    product = ProductOut(id=product_id, **body.model_dump())
+    app.state.products[product_id] = product
+    return product
 
 
-@app.delete("/notes/{note_id}", status_code=204, response_class=Response, responses={404: {"model": ErrorResponse}}, tags=["笔记 CRUD"])
-async def delete_note(note_id: Annotated[int, Path(ge=1)]):
-    find_note(note_id)
-    del app.state.notes[note_id]
+@app.delete("/products/{product_id}", status_code=204, response_class=Response, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
+async def delete_product(product_id: Annotated[int, Path(ge=1)]):
+    find_product(product_id)
+    del app.state.products[product_id]
     return Response(status_code=204)
 
 
@@ -220,9 +222,9 @@ async def upstream_demo(
 
 @app.post("/generate", tags=["普通响应与流式响应"])
 async def generate(payload: PromptIn):
-    # 确定性输出,无模型 SDK 或模型密钥;等待三步完成后一次返回。
+    # 商品清单导出进度的技术演示:模拟三个步骤,不生成实际导出文件。
     parts = []
-    for text in ["收到:", payload.prompt, "。处理完成。"]:
+    for text in ["准备导出商品清单:", payload.prompt, "。导出进度演示完成。"]:
         await asyncio.sleep(0.4)
         parts.append(text)
     return {"text": "".join(parts)}
@@ -235,7 +237,7 @@ def encode_sse(event: str, data: dict) -> str:
 
 async def token_events(request: Request, prompt: str):
     try:
-        for text in ["收到:", prompt, "。处理完成。"]:
+        for text in ["准备导出商品清单:", prompt, "。导出进度演示完成。"]:
             await asyncio.sleep(0.4)
             if await request.is_disconnected():
                 return
@@ -248,7 +250,7 @@ async def token_events(request: Request, prompt: str):
 @app.get("/stream", tags=["普通响应与流式响应"])
 async def stream(
     request: Request,
-    prompt: Annotated[str, Query(min_length=1, max_length=200)] = "hello",
+    prompt: Annotated[str, Query(min_length=1, max_length=200)] = "catalog",
 ):
     return StreamingResponse(
         token_events(request, prompt),
@@ -259,6 +261,7 @@ async def stream(
 
 @app.websocket("/ws")
 async def websocket_echo(websocket: WebSocket):
+    # 商品咨询传输演示:回显收到的文本,不实现客服逻辑或消息存储。
     await websocket.accept()
     try:
         while True:

+ 80 - 51
examples/test_main.py

@@ -9,7 +9,7 @@ from main import app
 
 @pytest.fixture
 def client():
-    app.state.notes.clear()
+    app.state.products.clear()
     app.state.next_id = 1
     with TestClient(app) as test_client:
         yield test_client
@@ -19,90 +19,119 @@ def test_health_and_docs(client):
     assert client.get("/health").json() == {"status": "ok"}
     assert client.get("/docs").status_code == 200
     paths = client.get("/openapi.json").json()["paths"]
-    assert "/notes" in paths
-    assert paths["/notes"]["post"]["responses"]["422"]["content"]["application/json"]["schema"] == {
+    assert "/products" in paths
+    assert paths["/products"]["post"]["responses"]["422"]["content"]["application/json"]["schema"] == {
         "$ref": "#/components/schemas/ErrorResponse"
     }
 
 
 def test_crud_lifecycle(client):
-    created = client.post("/notes", json={"title": "REST 笔记"})
+    created = client.post("/products", json={"name": "陶瓷杯"})
     assert created.status_code == 201
-    assert created.json() == {"id": 1, "title": "REST 笔记", "content": "", "pinned": False}
-    assert created.headers["location"] == "/notes/1"
+    assert created.json() == {"id": 1, "name": "陶瓷杯", "description": "", "is_active": False, "price_cents": 0}
+    assert created.headers["location"] == "/products/1"
     assert client.get(created.headers["location"]).json() == created.json()
-    assert client.get("/notes/1").json() == created.json()
-    replacement = {"title": "修改后的标题", "content": "PUT 完整替换", "pinned": True}
-    updated = client.put("/notes/1", json=replacement)
+    assert client.get("/products/1").json() == created.json()
+    replacement = {"name": "蓝色陶瓷杯", "description": "350ml", "is_active": True, "price_cents": 1990}
+    updated = client.put("/products/1", json=replacement)
     assert updated.status_code == 200
     assert updated.json() == {"id": 1, **replacement}
-    removed = client.delete("/notes/1")
+    removed = client.delete("/products/1")
     assert removed.status_code == 204
     assert removed.content == b""
-    assert client.get("/notes").json() == []
-    assert client.get("/notes/1").status_code == 404
-    assert client.post("/notes", json={"title": "新笔记"}).json()["id"] == 2
+    assert client.get("/products").json() == []
+    assert client.get("/products/1").status_code == 404
+    assert client.post("/products", json={"name": "新商品"}).json()["id"] == 2
 
 
 def test_list_filter_and_limit(client):
-    client.post("/notes", json={"title": "FastAPI", "content": "request"})
-    client.post("/notes", json={"title": "第二篇", "content": "fastapi response"})
-    client.post("/notes", json={"title": "Docker"})
-    assert len(client.get("/notes", params={"q": "FASTAPI"}).json()) == 2
-    assert len(client.get("/notes", params={"q": "fastapi", "limit": 1}).json()) == 1
-    assert client.get("/notes", params={"q": "unknown"}).json() == []
+    client.post("/products", json={"name": "Ceramic mug", "description": "350ml"})
+    client.post("/products", json={"name": "马克杯", "description": "white ceramic"})
+    client.post("/products", json={"name": "帆布袋"})
+    assert len(client.get("/products", params={"q": "CERAMIC"}).json()) == 2
+    assert len(client.get("/products", params={"q": "ceramic", "limit": 1}).json()) == 1
+    assert client.get("/products", params={"q": "unknown"}).json() == []
 
 
-def test_default_limit_and_combined_pinned_filter(client):
+def test_default_limit_and_combined_is_active_filter(client):
     for index in range(12):
-        client.post("/notes", json={
-            "title": f"note {index}",
-            "content": "match" if index in (1, 2, 3) else "",
-            "pinned": index % 2 == 0,
+        client.post("/products", json={
+            "name": f"product {index}",
+            "description": "match" if index in (1, 2, 3) else "",
+            "is_active": index % 2 == 0,
         })
-    assert len(client.get("/notes").json()) == 10
-    pinned = client.get("/notes", params={"pinned": "true"}).json()
-    unpinned = client.get("/notes", params={"pinned": "false"}).json()
-    assert len(pinned) == len(unpinned) == 6
-    assert all(note["pinned"] is True for note in pinned)
-    assert all(note["pinned"] is False for note in unpinned)
-    combined = client.get("/notes", params={"q": "match", "pinned": "false", "limit": 1}).json()
-    assert [note["id"] for note in combined] == [2]
-
-
-@pytest.mark.parametrize("title", ["", "   ", "x" * 121])
-def test_invalid_title_reports_safe_validation_errors(client, title):
-    response = client.post("/notes", json={"title": title})
+    assert len(client.get("/products").json()) == 10
+    is_active = client.get("/products", params={"is_active": "true"}).json()
+    inactive = client.get("/products", params={"is_active": "false"}).json()
+    assert len(is_active) == len(inactive) == 6
+    assert all(product["is_active"] is True for product in is_active)
+    assert all(product["is_active"] is False for product in inactive)
+    combined = client.get("/products", params={"q": "match", "is_active": "false", "limit": 1}).json()
+    assert [product["id"] for product in combined] == [2]
+
+
+@pytest.mark.parametrize("name", ["", "   ", "x" * 121])
+def test_invalid_name_reports_safe_validation_errors(client, name):
+    response = client.post("/products", json={"name": name})
     assert response.status_code == 422
     error = response.json()["error"]
     assert error["code"] == "validation_error"
-    assert error["details"][0]["loc"] == ["body", "title"]
+    assert error["details"][0]["loc"] == ["body", "name"]
     assert set(error["details"][0]) == {"loc", "msg", "type"}
-    assert client.get("/notes").json() == []
+    assert client.get("/products").json() == []
 
 
-@pytest.mark.parametrize("missing", ["title", "content", "pinned"])
+@pytest.mark.parametrize("missing", ["name", "description", "is_active", "price_cents"])
 def test_put_requires_every_mutable_field(client, missing):
-    client.post("/notes", json={"title": "保留的笔记"})
-    body = {"title": "替换", "content": "内容", "pinned": False}
+    client.post("/products", json={"name": "保留的商品"})
+    body = {"name": "替换", "description": "内容", "is_active": False, "price_cents": 0}
     del body[missing]
-    response = client.put("/notes/1", json=body)
+    response = client.put("/products/1", json=body)
     assert response.status_code == 422
-    assert client.get("/notes/1").json()["title"] == "保留的笔记"
+    assert client.get("/products/1").json()["name"] == "保留的商品"
+
+
+@pytest.mark.parametrize("price", [-1, 1.5, "1990", True, None, 2147483648])
+def test_price_rejects_invalid_create_and_replace_without_mutation(client, price):
+    original = client.post("/products", json={"name": "陶瓷杯", "price_cents": 1990}).json()
+    rejected = client.post("/products", json={"name": "无效价格", "price_cents": price})
+    assert rejected.status_code == 422
+    assert rejected.json()["error"]["details"][0]["loc"] == ["body", "price_cents"]
+    replacement = {"name": "不应保存", "description": "", "is_active": True, "price_cents": price}
+    assert client.put("/products/1", json=replacement).status_code == 422
+    assert client.get("/products/1").json() == original
+    assert client.get("/products").json() == [original]
+
+
+@pytest.mark.parametrize("price", [0, 1990, 2147483647])
+def test_price_integer_boundaries_are_preserved(client, price):
+    response = client.post("/products", json={"name": "陶瓷杯", "price_cents": price})
+    assert response.status_code == 201
+    assert response.json()["price_cents"] == price
+    assert type(response.json()["price_cents"]) is int
+
+
+def test_description_size_matches_database_text_budget(client):
+    original = client.post("/products", json={"name": "陶瓷杯", "description": "杯" * 16000})
+    assert original.status_code == 201
+    assert client.post("/products", json={"name": "过长描述", "description": "杯" * 16001}).status_code == 422
+    replacement = {"name": "过长描述", "description": "杯" * 16001, "is_active": False, "price_cents": 0}
+    assert client.put("/products/1", json=replacement).status_code == 422
+    assert client.get("/products/1").json() == original.json()
 
 
 @pytest.mark.parametrize("method,kwargs", [
     ("get", {}),
-    ("put", {"json": {"title": "missing", "content": "", "pinned": False}}),
+    ("put", {"json": {"name": "missing", "description": "", "is_active": False, "price_cents": 0}}),
     ("delete", {}),
 ])
-def test_missing_note(client, method, kwargs):
-    response = getattr(client, method)("/notes/999", **kwargs)
+def test_missing_product(client, method, kwargs):
+    response = getattr(client, method)("/products/999", **kwargs)
     assert response.status_code == 404
-    assert response.json() == {"error": {"code": "http_error", "message": "Note not found", "details": []}}
+    assert response.json() == {"error": {"code": "http_error", "message": "Product not found", "details": []}}
 
 
-@pytest.mark.parametrize("url", ["/notes?limit=0", "/notes?limit=101", "/notes?pinned=invalid", "/notes/no-number", "/slow?delay=-1", "/slow?delay=6"])
+@pytest.mark.parametrize("url", ["/products?limit=0", "/products?limit=101", "/products?is_active=invalid", "/products/no-number", "/slow?delay=-1", "/slow?delay=6"])
 def test_query_and_path_validation(client, url):
     response = client.get(url)
     assert response.status_code == 422
@@ -163,11 +192,11 @@ def test_cors_preflight_permits_local_client_only(client):
         "Access-Control-Request-Method": "POST",
         "Access-Control-Request-Headers": "content-type",
     }
-    accepted = client.options("/notes", headers=headers)
+    accepted = client.options("/products", headers=headers)
     assert accepted.status_code == 200
     assert accepted.headers["access-control-allow-origin"] == "http://localhost:5500"
     headers["Origin"] = "https://example.org"
-    rejected = client.options("/notes", headers=headers)
+    rejected = client.options("/products", headers=headers)
     assert rejected.status_code == 400
     assert "access-control-allow-origin" not in rejected.headers
 

+ 27 - 19
examples/web-client.html

@@ -25,28 +25,31 @@
   <h1>让浏览器调用你的 FastAPI</h1>
   <p>先启动 <code>http://127.0.0.1:8000</code> 的 API,再通过 <code>http://localhost:5500/web-client.html</code> 打开此页。在开发者工具的 Network 面板中观察请求。</p>
   <section>
-    <h2>01 · POST /notes</h2>
-    <p>浏览器发送 JSON 请求体,服务创建笔记并返回 <code>201</code>。</p>
-    <label for="note-title">笔记标题</label>
-    <input id="note-title" value="我的第一条 API 笔记" maxlength="120">
-    <div><button id="create-note" type="button">创建笔记</button></div>
-    <pre id="note-output" aria-live="polite">等待请求</pre>
+    <h2>01 · POST /products</h2>
+    <p>浏览器发送 JSON 请求体,服务创建商品并返回 <code>201</code>。</p>
+    <label for="product-name">商品名称</label>
+    <input id="product-name" value="陶瓷杯" maxlength="120">
+    <label for="product-price">价格(分,1990 表示 19.90 元)</label>
+    <input id="product-price" type="number" value="1990" min="0" max="2147483647" step="1">
+    <p class="hint">新商品默认未上架;0 分只用于免费教学样品。</p>
+    <div><button id="create-product" type="button">创建商品</button></div>
+    <pre id="product-output" aria-live="polite">等待请求</pre>
   </section>
   <section>
     <h2>02 · GET /stream · SSE</h2>
-    <p>服务连续发送 <code>token</code> 事件,最后发送 <code>done</code>。客户端收到结束事件后主动关闭连接。</p>
-    <label for="prompt">输入文本</label>
-    <input id="prompt" value="分块返回这段文本,观察连接何时结束。" maxlength="200">
+    <p>模拟商品清单导出进度:服务连续发送 <code>token</code> 事件,最后发送 <code>done</code>。本实验只演示进度传输,不生成文件。</p>
+    <label for="prompt">导出清单名称</label>
+    <input id="prompt" value="上架商品清单" maxlength="200">
     <div><button id="start-stream" type="button">开始接收</button><button id="stop-stream" class="secondary" type="button" disabled>停止</button></div>
     <p id="stream-status" class="hint" role="status">未连接</p>
     <pre id="stream-output" aria-live="polite">收到的文本会显示在这里</pre>
   </section>
   <section>
     <h2>03 · /ws · WebSocket</h2>
-    <p>连接保持期间,客户端可以多次发送消息。这里的文本 <code>ping</code> 是应用约定,不是协议控制帧。</p>
+    <p>用商品咨询演示双向传输,服务仅回显问题。这里的文本 <code>ping</code> 是应用约定,不是协议控制帧。</p>
     <div><button id="connect-ws" type="button">连接</button><button id="close-ws" class="secondary" type="button" disabled>断开</button></div>
     <label for="ws-message">消息</label>
-    <input id="ws-message" value="你好,FastAPI">
+    <input id="ws-message" value="这款陶瓷杯有多大?">
     <div><button id="send-ws" type="button" disabled>发送消息</button><button id="ping-ws" class="secondary" type="button" disabled>发送 ping</button></div>
     <p id="ws-status" class="hint" role="status">未连接</p>
     <pre id="ws-output" aria-live="polite">服务端消息会显示在这里</pre>
@@ -58,21 +61,26 @@
     let stream = null;
     let socket = null;
 
-    $('create-note').onclick = async () => {
-      $('create-note').disabled = true;
-      $('note-output').textContent = '发送中…';
+    $('create-product').onclick = async () => {
+      $('create-product').disabled = true;
+      $('product-output').textContent = '发送中…';
       try {
-        const response = await fetch(`${API}/notes`, {
+        const priceText = $('product-price').value.trim();
+        const priceCents = Number(priceText);
+        if (!priceText || !Number.isInteger(priceCents) || priceCents < 0 || priceCents > 2147483647) {
+          throw new Error('价格需为 0~2147483647 之间的整数分');
+        }
+        const response = await fetch(`${API}/products`, {
           method: 'POST',
           headers: {'Content-Type': 'application/json'},
-          body: JSON.stringify({title: $('note-title').value, content: '', pinned: false}),
+          body: JSON.stringify({name: $('product-name').value, description: '', is_active: false, price_cents: priceCents}),
         });
         const data = await response.json();
-        $('note-output').textContent = `HTTP ${response.status}\n${JSON.stringify(data, null, 2)}`;
+        $('product-output').textContent = `HTTP ${response.status}\n${JSON.stringify(data, null, 2)}`;
       } catch (error) {
-        $('note-output').textContent = `请求失败:${error.message}\n检查服务是否启动、页面是否来自 localhost:5500。`;
+        $('product-output').textContent = `请求失败:${error.message}\n检查服务是否启动、页面是否来自 localhost:5500。`;
       } finally {
-        $('create-note').disabled = false;
+        $('create-product').disabled = false;
       }
     };
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 310 - 1392
public/content/api-database.json


Dosya farkı çok büyük olduğundan ihmal edildi
+ 456 - 237
public/content/database.json


+ 1404 - 0
public/content/distributed.json

@@ -0,0 +1,1404 @@
+[
+  {
+    "id": "01",
+    "kind": "theory",
+    "group": "架构选择与数据分布",
+    "title": "从模块化单体到微服务与事件驱动",
+    "summary": "画出一个应用加独立导出进程的方案,并说明何时值得继续拆分。",
+    "objectives": [
+      "画出新增商品到下载清单的完整路线",
+      "指出哪些箭头需要经过网络",
+      "为一次拆分写出收益和新增成本"
+    ],
+    "concepts": [
+      {
+        "title": "模块化单体:同一家公司里的不同部门",
+        "body": "回看“画出模块化单体版本”的推演。商品管理、商品分类、商品评价、清单导出和进度查询可以是同一应用里的清晰模块。它们通过接口协作,应用整体发布,也可以运行多个副本。单体描述部署边界,模块化描述职责边界;一个应用并不等于所有逻辑写在一个文件。"
+      },
+      {
+        "title": "微服务:部门成为独立经营的团队",
+        "body": "回看“只拆一个有理由的边界”的推演。把商品查询、清单导出或通知拆成独立服务,可以分别发布和扩容。一次函数调用变成网络请求后,必须面对延迟、超时、版本兼容和局部失败。拆分的价值应来自独立变化或资源需求,服务数量本身不是质量指标。"
+      },
+      {
+        "title": "事件驱动:完成一件事,再通知关心它的人",
+        "body": "回看“比较同步调用与事件通知”的推演。“商品清单已导出”是已经发生的事实,进度通知可订阅这一事件;“请导出商品清单”则是请求某个执行者做事的命令。事件驱动描述协作方式,可以和单体或微服务组合;下游稍后完成时,需要显式状态和查询入口。"
+      },
+      {
+        "title": "商品服务的技术责任",
+        "body": "回看“固定一条用户旅程”的推演。贯穿案例只管理商品、分类和评价,并支持商品清单导出与进度通知。服务端负责输入校验、持久状态、任务执行和结果交付;商品价格使用非负整数 price_cents,0 表示免费教学样品。业务保持简单,把注意力放在接口、数据和失败恢复上。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "固定一条用户旅程",
+        "body": "把“新增教学水杯→选择商品→导出→稍后下载”写成六个框。先不命名架构,为每个框填输入、输出,以及结果最终保存在哪。",
+        "explanation": "用户新增教学水杯,填写名称、描述和整数价格,再选择几件商品导出清单,随后查询进度并下载文件。链路依次是:入口校验 → 保存商品 → 固定导出快照 → 执行任务 → 保存文件 → 更新状态并通知。先解释每个框输入什么、输出什么、谁保存事实。",
+        "expected": "图中应能找到商品记录、导出时固定的数据和文件;只保存一个“已受理”响应还不能下载清单。"
+      },
+      {
+        "title": "画出模块化单体版本",
+        "body": "给上一步的商品管理、分类、评价、导出受理和任务查询画同一个应用边框;把数据库、Redis、文件放到外面,分别标出跨框箭头。",
+        "explanation": "把商品管理、分类、评价、导出受理和任务查询放在一个应用边框内,数据库、Redis 和文件存储画在边框外。模块间箭头是进程内调用,跨出边框的箭头需要网络。多开两个应用副本仍可以是单体。",
+        "expected": "应用内部调用不经过网络;访问库、缓存和文件服务的箭头跨出进程。复制整个应用不会自动增加业务边界。"
+      },
+      {
+        "title": "只拆一个有理由的边界",
+        "body": "现在新增一个条件:导出计算比商品查询慢得多。只移动负责计算的框,比较移动前后能否单独增加执行进程。",
+        "explanation": "假设商品清单导出很耗时,需要独立增加执行资源,可以把执行器作为独立 Worker 进程,经队列接收任务。这里先得到“API + Worker”,未必需要拆成多个微服务。仅当商品查询或通知确实出现独立发布和扩容需求时,再考虑新的边界。",
+        "expected": "API 继续受理和查询,独立 Worker 消费任务;可以只增加 Worker,暂时无需拆商品和分类服务。"
+      },
+      {
+        "title": "比较同步调用与事件通知",
+        "body": "分别画“查看商品详情”和“导出完成后通知页面”的时序。把页面必须等待的箭头用实线、可稍后发生的通知用虚线。",
+        "explanation": "商品详情和任务查询需要立即返回当前数据,适合同步读取;导出完成后的进度通知可以接收“商品清单已导出”事件。同步链条越长,用户越容易被最慢依赖拖住;异步解耦后仍要处理重复、延迟和失败。",
+        "expected": "详情读取需要即时结果;完成通知可以延迟。等待链减少后,通知失败与重复需要独立处理。"
+      },
+      {
+        "title": "写出选择理由和转向条件",
+        "body": "假设三个人维护全部代码,写下本轮保留同一应用的模块、独立运行的角色,以及一个未来值得再拆分的触发条件。",
+        "explanation": "对于三人维护、需求仍在变化的商品服务,模块化单体加独立导出执行器是合理起点。记录“商品查询需要独立扩容”“通知需要独立发布”等未来拆分条件。新增组件时说明具体收益和维护成本。",
+        "expected": "方案可从单应用加独立导出进程开始;每个新增边界都应对应具体扩容或发布需求,而非仅增加服务数量。"
+      }
+    ],
+    "checks": [
+      "能指出模块边界与网络边界,并说明两者不一定重合。",
+      "能用一个具体业务原因解释为什么拆服务,或为什么暂时保持单体。",
+      "能区分“执行商品清单导出”的命令与“商品清单已导出”的事件。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "每个功能都拆成服务,改一个字段要协调多个发布。",
+        "cause": "按技术名词切分,没有确定稳定的业务责任与数据归属。",
+        "fix": "先整理模块接口和数据所有权,再依据独立变化、资源或权限需求拆分。"
+      },
+      {
+        "symptom": "加了消息队列后,页面永远不知道任务有没有完成。",
+        "cause": "只画事件流,没有业务状态和查询入口。",
+        "fix": "为长任务定义编号、进度、终态和结果引用,让异步过程对用户可解释。"
+      }
+    ],
+    "exercise": {
+      "task": "一个三人团队的商品服务只有清单导出缓慢,其他接口负载很低。给出最小合理架构,并列出两个未来拆分服务的触发条件。",
+      "hint": "把发布复杂度、计算资源和用户等待分别考虑。",
+      "answer": "保留模块化 API,由数据库保存商品与导出任务,队列把导出交给独立 Worker,文件存入共享存储,用户用任务编号查询进度。API 和 Worker 分别分配资源。未来商品查询需要独立扩容,或通知功能有独立发布需求时,再考虑拆分。新增队列也带来交付和恢复成本;如果承诺关闭页面后仍能完成,就需要持久受理与恢复机制。"
+    },
+    "interview": {
+      "question": "什么时候应该从单体迁移到微服务?",
+      "answer": "先指出现有痛点是否来自独立发布、组织边界、扩容或权限隔离,再判断清晰的数据所有权是否已经存在。微服务可以缩小某些变更和资源影响,也会引入网络失败、跨服务一致性与运维成本。能用模块化或独立 Worker 解决的问题,不必靠全面拆分解决。",
+      "followUps": [
+        "单体能否横向扩容?为什么?",
+        "同步微服务链与事件驱动链分别如何向用户表达失败?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Microsoft:常见 Web 应用架构与单体边界",
+        "url": "https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures"
+      },
+      {
+        "label": "Microsoft:微服务架构",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/microservices/"
+      },
+      {
+        "label": "Microsoft:事件驱动架构",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven"
+      }
+    ],
+    "intro": {
+      "scenario": "商品能保存、清单也能导出后,团队准备增加服务器。应该先把哪部分独立运行?",
+      "startingPoint": [
+        "已完成商品接口、数据库与异步任务模块,能画出一次商品导出的实际执行顺序。",
+        "本模块只在纸面推演;准备一张纸,把 API、任务记录和文件画出来即可,不操作生产系统。"
+      ],
+      "outcome": "画出一个应用加独立导出进程的方案,并说明何时值得继续拆分。"
+    }
+  },
+  {
+    "id": "02",
+    "kind": "theory",
+    "group": "架构选择与数据分布",
+    "title": "高可用与负载均衡:多开一台为什么还不够",
+    "summary": "推演副本故障后的请求去向、数据可见性和容量缺口。",
+    "objectives": [
+      "让两台 API 查询到同一任务",
+      "算出坏一台后还差多少处理能力",
+      "为暂不可用的导出写出准确提示"
+    ],
+    "concepts": [
+      {
+        "title": "负载均衡:把工作分给合适的窗口",
+        "body": "回看“从两次请求看状态归属”的推演。入口将请求分给多个 API 副本。轮询适合成本接近的请求;最少连接或考虑负载的策略可改善某些长短任务混杂的情况。一次清单导出可能占用几十秒,一次状态查询只需很短时间,请求个数相等并不意味着工作量相等。"
+      },
+      {
+        "title": "高可用:坏掉一部分后仍满足用户目标",
+        "body": "回看“检查切换后的承载能力”的推演。两个 API 副本能减小单进程故障影响,但若都依赖一台数据库,数据库故障仍会影响全站。必须沿入口、应用、存储和依赖逐层观察。高可用是整条业务路径的性质,不是某个盒子旁写“×2”就完成。"
+      },
+      {
+        "title": "故障域:会一起失效的范围",
+        "body": "回看“推演一次副本不可用”的推演。同一主机上的两个进程可能随机器一起停止;不同主机也可能共用电源、网络、机房或错误配置。跨故障域布置副本可以降低共同失效风险,却需要承担额外网络延迟、费用和数据同步成本。"
+      },
+      {
+        "title": "无状态副本不等于系统没有状态",
+        "body": "回看“从两次请求看状态归属”的推演。让任何 API 副本都能从持久存储读取商品和导出任务,才能随时接替工作。若任务只在 API-A 的内存,切换到 API-B 会查询不到。流式连接仍属于一个具体实例;断开后的恢复需要业务编号和可续接协议。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "从两次请求看状态归属",
+        "body": "画 API-A、API-B,给 A 放一条导出记录。让用户先提交到 A,再查询 B;分别试一次“记录只在 A 内存”和“记录在共享库”。",
+        "explanation": "用户向 API-A 提交任务,下一次查询被分配到 API-B。若 B 读取共享任务库,可以继续回答;若 A 只写自己的字典,B 会误报不存在。由此理解为何负载均衡之前必须先明确数据存放位置。",
+        "expected": "内存方案中 B 查不到;共享记录方案中 B 可返回同一 job_id 的进度,前提是共享库可达。"
+      },
+      {
+        "title": "推演一次副本不可用",
+        "body": "在两次健康探测中间让 A 停止响应。沿在途请求与后续请求分别画箭头,再比较读取重试和提交重试。",
+        "explanation": "A 停止响应,健康检查在下一次探测后才会发现,流量切换存在延迟,部分在途请求可能已经失败。客户端可按操作语义重试:查询通常容易重试,提交任务需要同一幂等键,避免在 B 再创建一份任务。",
+        "expected": "探测发现前仍可能有请求失败;查询可再读,提交需沿用同一意图编号,切到 B 不能再创建一份。"
+      },
+      {
+        "title": "检查切换后的承载能力",
+        "body": "写下 A、B 各 100 次/秒,总需求 160 次/秒。划掉 A,计算 B 的缺口,选择扩容、限制受理或降级的一种补救。",
+        "explanation": "假设两个副本各能稳定处理 100 个轻请求/秒,平时总流量为 160。一个副本故障后,剩下 100 的能力无法承担 160。冗余之外还需要容量余量、扩容或降级;健康检查不会制造新的计算资源。",
+        "expected": "故障后差 60 次/秒;即使入口立即发现故障并切换,剩余副本仍无法维持原服务目标。"
+      },
+      {
+        "title": "区分存活与可以接客",
+        "body": "保持 B 的进程运行,但让它无法从数据库取得连接。分别判断“进程存在”和“能完成商品查询”两个问题。",
+        "explanation": "进程仍活着,只能证明某个基础条件成立;数据库连接池耗尽时,它可能无法完成业务。健康判断应帮助入口选择可服务实例,同时避免把共享依赖的一次波动变成所有实例反复摘除和重启。",
+        "expected": "进程存在可以为真,业务就绪可以为假;共享依赖故障时所有副本都可能受影响,反复重启不一定有用。"
+      },
+      {
+        "title": "把降级结果写成用户能理解的话",
+        "body": "设文件服务停止、商品库正常。分别为商品查询、旧任务查询和新导出写一条用户会看到的结果。",
+        "explanation": "文件存储不可用时,系统可以继续展示商品和查询现有任务,暂缓新导出并给出明确状态。不能把缺失文件标成成功。可用性目标按商品查询、导出受理和导出完成分别定义。",
+        "expected": "查询可继续,导出明确等待或暂不受理;缺少文件的任务不能显示可下载成功。"
+      }
+    ],
+    "checks": [
+      "能指出两个 API 副本仍共享哪些故障风险。",
+      "能算出失去一个副本后,剩余容量是否足够。",
+      "能说明健康检查有发现延迟,无法挽救已经中断的请求。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "切到另一台机器后用户任务消失。",
+        "cause": "商品或任务只保存在本机内存,负载均衡只转发请求。",
+        "fix": "让可恢复的业务状态进入共享持久存储,连接中断通过业务编号续查。"
+      },
+      {
+        "symptom": "故障时所有流量涌向剩余副本,随后全部超时。",
+        "cause": "副本数量增加了,但没有给故障场景预留容量。",
+        "fix": "按故障后的容量做预算,配合限流、隔离和明确的降级行为。"
+      }
+    ],
+    "exercise": {
+      "task": "商品服务 有三台 API,每台稳定容量为 80 请求/秒,日常流量为 180。要求任意一台故障后仍承接全部流量,应如何调整?",
+      "hint": "先计算 N−1 场景的剩余容量,再判断是否所有请求必须保留同等服务水平。",
+      "answer": "失去一台后仅剩 160 请求/秒,小于 180,因此现方案不满足目标。可提升每台稳定容量到至少 90,但这只刚好满足数学下限,实际还要留延迟和突发余量;也可增加到四台,失去一台后理论容量为 240。若预算不允许,可在故障时优先保障查询与取消,限制新导出请求,并把这种降级写进服务目标。还需确认数据库和文件存储吞吐能承接,增加 API 并不自动扩大下游容量。"
+    },
+    "interview": {
+      "question": "部署两个实例是否就实现了高可用?",
+      "answer": "没有。需要检查入口是否冗余、实例是否跨共同故障域、状态是否可接替、依赖是否存在单点,以及故障后剩余容量是否足够。健康检查和切换也有时间窗口,用户侧需要按请求语义恢复。最终用业务可用性目标评价整条路径。",
+      "followUps": [
+        "长连接和普通短请求对负载分配有何不同?",
+        "为什么只检查进程存活可能得到错误的健康结论?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Google SRE:前端负载均衡",
+        "url": "https://sre.google/sre-book/load-balancing-frontend/"
+      },
+      {
+        "label": "Google SRE:数据中心内负载均衡",
+        "url": "https://sre.google/sre-book/load-balancing-datacenter/"
+      },
+      {
+        "label": "Google SRE:处理过载",
+        "url": "https://sre.google/sre-book/handling-overload/"
+      }
+    ],
+    "intro": {
+      "scenario": "用户提交导出后,第二次查询落到另一台 API,页面却说任务不存在。多开一台为何出现新问题?",
+      "startingPoint": [
+        "完成第 01 课,已有 API 与 Worker 的边界图。",
+        "沿用同一张图,给 API 画出 A、B 两份,把任务目前保存的位置圈出来。"
+      ],
+      "outcome": "推演副本故障后的请求去向、数据可见性和容量缺口。"
+    }
+  },
+  {
+    "id": "03",
+    "kind": "theory",
+    "group": "架构选择与数据分布",
+    "title": "复制与分片:多份副本和分成几份有什么不同",
+    "summary": "分别用复制和分片解释旧读与容量问题,并列出扩容时必须迁移的工作。",
+    "objectives": [
+      "追踪一次写后读到旧状态的原因",
+      "按常见查询选择记录放置规则",
+      "找出新增分片仍解决不了的热点"
+    ],
+    "concepts": [
+      {
+        "title": "复制:同一份账本的多个副本",
+        "body": "回看“跟踪一次写后立即读”的推演。主副本把变化发送给其他副本,主要服务于读扩展和故障接替。异步复制先向用户确认,再让其他副本追上;同步确认要等待约定的副本达到约定阶段。等待接收、持久化和回放不是同一个条件。"
+      },
+      {
+        "title": "分片:每个分柜保存不同的一部分",
+        "body": "回看“从查询问题反推分片键”的推演。按用户把导出任务分到不同数据库,各分片只承担部分数据和写入。一个分片内部又可以有多个副本,因此复制和分片可以同时使用。分片引入路由、扩容迁移、跨片查询和事务的额外复杂度。"
+      },
+      {
+        "title": "分片键决定哪些工作能待在一起",
+        "body": "回看“从查询问题反推分片键”的推演。按 user_id 分片有利于读取同一用户的导出任务,但超大用户可能成为热点。按 job_id 均匀分散写入,则列出一个用户的全部任务可能需要跨片汇总。没有同时适合所有读写模式的万能分片键。"
+      },
+      {
+        "title": "副本不是历史备份",
+        "body": "回看“把扩容当作数据迁移问题”的推演。复制通常也会把误删或坏数据传过去。备份保存可恢复的历史版本,帮助回到错误之前;副本帮助快速接替近期运行状态。前者关心恢复点和保留周期,后者关心同步进度与接管方式。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "跟踪一次写后立即读",
+        "body": "写出主库 running→succeeded 的时间,再让只读副本晚 2 秒应用它。安排一次落在这 2 秒内的查询并写出页面状态。",
+        "explanation": "任务在主库已经从 running 变为 succeeded,但查询被路由到尚未回放的只读副本,用户看到 running。不是写入一定丢了,而是读到了较旧视图。可让关键写后读取走权威节点,或携带版本并等待达到该版本。",
+        "expected": "用户可能看到 running,即使主库已完成;再次看到旧值本身不能证明写入丢失。"
+      },
+      {
+        "title": "比较同步和异步的代价",
+        "body": "为一笔任务更新画两种确认时刻:等待远端保存后确认,以及本地保存后立即确认。随后在远端收到更新前让主库不可用。",
+        "explanation": "若每次确认都等待远端副本,用户要承担网络和存储等待时间;远端无法响应时,写入可用性也受影响。若不等待,切换到落后的副本可能丢失已确认但未复制的变化。选择时要说明数据损失容忍度和延迟目标。",
+        "expected": "等待远端需要额外延迟且可能阻塞写入;不等待时,切换到落后副本可能失去部分已确认变化。"
+      },
+      {
+        "title": "从查询问题反推分片键",
+        "body": "把“本用户最近任务”和“全站导出数量”各写成一张查询卡。尝试按 user_id 分配记录,数出每张卡要访问几个位置。",
+        "explanation": "商品服务 最常见的请求是“查询本用户最近的任务”。按用户分片可让请求只去一个位置,路由简单且更容易做用户隔离;若还需要全站导出统计,则应考虑单独汇总视图,而不是让每次请求扫所有分片。",
+        "expected": "用户任务查询可落在一个分片;全站统计需要跨片或独立汇总,选键必须考虑查询形状。"
+      },
+      {
+        "title": "识别分片后仍存在的热点",
+        "body": "假设用户 A 贡献 60% 的导出。画四个按用户分配的分片,再新增一个空分片,观察 A 的记录是否自动变少。",
+        "explanation": "若一个高频导出用户贡献了 60% 的任务,增加其他分片不会自动减轻它所在分片的压力。可以为大用户单独分片、再按子键细分或隔离其配额,但需要重新考虑同用户事务和查询如何合并。",
+        "expected": "新增空分片不会减轻 A 所在分片;拆大用户或限制配额时还要处理原来同用户查询和事务。"
+      },
+      {
+        "title": "把扩容当作数据迁移问题",
+        "body": "把分片从 4 个增加到 5 个,列出搬迁时“读旧位置、读新位置、同时写入、切换路由”的先后与校验点。",
+        "explanation": "新增一个分片后,必须决定谁搬过去、搬迁期间读哪边、如何处理同时写入、何时切换路由以及如何确认无遗漏。直接把取模分母从 4 改成 5,会让大量键映射到新位置;一致性哈希减少部分迁移,也不能替代迁移协议。",
+        "expected": "只改取模公式会让许多记录被查询到错误位置;新旧路由、迁移进度与数据核对都需要明确协议。"
+      }
+    ],
+    "checks": [
+      "能说明“3 个分片,每片 2 个副本”包含 6 个数据实例,但并非 6 份完整数据。",
+      "能解释读到旧任务状态与写丢失之间的区别。",
+      "能为一个分片键列出方便的查询、困难的查询和潜在热点。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "加了很多只读副本,写入仍然很慢。",
+        "cause": "写入瓶颈还在主节点、锁竞争或存储,读副本没有分担该写入路径。",
+        "fix": "先确认瓶颈;通过事务缩短、索引和数据模型等办法优化,确有单节点容量限制再考虑分片。"
+      },
+      {
+        "symptom": "所有副本都有同样的误删记录。",
+        "cause": "把复制当作可回溯的备份,错误变化也被同步了。",
+        "fix": "分别设计副本接替与历史恢复,明确恢复点以及备份依赖。"
+      }
+    ],
+    "exercise": {
+      "task": "一个 商品服务有 1 万个用户,90% 查询按用户列任务,其中一个用户占 40% 写入。应怎样选择分片策略,并解释备份是否还能省掉?",
+      "hint": "先利用主要访问模式,再单独处理不均匀的超大用户。",
+      "answer": "可用用户路由表把普通用户分散到分片,使单用户查询保持局部;给超大用户独立分片和配额,必要时再按任务或时间细分。细分后需要额外的列表合并、索引和跨片操作设计,不能宣称已消除所有热点。全站统计可由异步汇总视图承担,并说明延迟。每个分片仍需合适的副本与备份:副本改善接替,备份保留误删前的历史,两者解决不同问题。"
+    },
+    "interview": {
+      "question": "分库分表之前应该考虑什么?",
+      "answer": "先证明单节点容量或吞吐已成为实际瓶颈,再列出主要查询、事务边界、增长与热点分布。选择分片键时同时讨论数据局部性和负载均匀性,最后补充路由、迁移、跨片查询、唯一标识和恢复成本。读副本主要分担读取,无法自动解决写入热点。",
+      "followUps": [
+        "按时间分片为什么容易出现最新分片热点?",
+        "分片后跨用户报表如何设计?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL:主备、流复制与同步复制",
+        "url": "https://www.postgresql.org/docs/current/warm-standby.html"
+      },
+      {
+        "label": "Microsoft:分片模式与取舍",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/sharding"
+      },
+      {
+        "label": "PostgreSQL:备份与恢复",
+        "url": "https://www.postgresql.org/docs/current/backup.html"
+      }
+    ],
+    "intro": {
+      "scenario": "导出已完成,刷新页面却仍显示处理中;另一方面,一台数据库快装不下所有记录了。",
+      "startingPoint": [
+        "完成第 02 课,能说明共享任务记录为何不能只放某台 API 内存。",
+        "画一份任务表,标出一条记录的写入位置和查询位置,以下数字均为纸面示例。"
+      ],
+      "outcome": "分别用复制和分片解释旧读与容量问题,并列出扩容时必须迁移的工作。"
+    }
+  },
+  {
+    "id": "04",
+    "kind": "theory",
+    "group": "架构选择与数据分布",
+    "title": "CAP 与一致性:断网时,谁还能回答",
+    "summary": "比较拒绝部分修改与两侧都接受修改的结果,明确商品读写各自的承诺。",
+    "objectives": [
+      "推演同一旧版本在两区同时被修改",
+      "指出每个选择牺牲了什么",
+      "把列表读取与保存修改分别定规则"
+    ],
+    "concepts": [
+      {
+        "title": "网络分区不是数据库分片",
+        "body": "回看“建立两个区域的共同初始值”的推演。网络分区表示一些节点互相收不到消息,节点本身可能仍在运行,也各自能接收用户请求。数据库分片是主动把数据拆开存放,两个词描述完全不同的问题。"
+      },
+      {
+        "title": "CAP 的 C、A 各有严格含义",
+        "body": "回看“选择由权威侧确认修改”的推演。这里的 C 指线性一致性:操作看起来按尊重实际先后关系的顺序作用于同一份数据。A 要求每个非故障节点收到的请求最终得到符合该操作的响应,不能靠一律报错充数。发生分区时,无法对所有请求同时保证这两点。"
+      },
+      {
+        "title": "最终一致性不承诺立刻同步",
+        "body": "回看“选择两侧继续接受修改”的推演。在没有新更新、通信恢复并持续完成同步与冲突处理等前提下,副本最终收敛。它本身不给出“最多 1 秒”的界限。产品若要求几秒内可见,还需要额外的延迟目标、监测和补偿路径。"
+      },
+      {
+        "title": "一致性目标需要落实到业务操作",
+        "body": "回看“把展示读取和修改确认分开”的推演。“同一商品版本只能接受一次修改”可由权威事务中的版本条件保护,防止后写静默覆盖先写;“商品列表稍后显示最新名称”可以接受短暂旧读。读己之写只保证用户能看到自己的已完成修改,比所有用户都共享实时顺序的要求更窄。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立两个区域的共同初始值",
+        "body": "在东、西两个框都写商品 42:name=水杯、version=7。切断两个框之间的箭头,只保留各自到用户的箭头,列出每侧无法知道的事实。",
+        "explanation": "东区和西区都保存商品 42:name 为“水杯”,version 为 7。随后区域间断开,但用户仍能访问两区。两侧都不知道另一边是否已经修改这个商品,增加本地缓存也无法补上缺失消息。",
+        "expected": "两侧都能收到用户请求,却不能及时知道对方是否已改过商品;“用户能访问”不等于两区能协调。"
+      },
+      {
+        "title": "选择由权威侧确认修改",
+        "body": "规定只有联系到权威节点的一侧能确认 version=7 的修改。东区可联系,西区不可联系;写下两位用户分别得到什么响应。",
+        "explanation": "若只有能联系权威节点或满足协调协议的一侧可以执行“WHERE version=7”的修改,另一侧拒绝或等待,就能保护同一版本只接受一次修改的约定,但部分请求无法正常完成。多数派还需要正确协议与持久化规则,不能只数节点证明一致性。",
+        "expected": "东区可接受有效修改;西区必须拒绝或等待。同一旧版本不被两侧同时确认,但部分用户修改无法完成。"
+      },
+      {
+        "title": "选择两侧继续接受修改",
+        "body": "取消上一步限制,让东区将名称改为白色水杯、西区改为透明水杯,并都确认成功。恢复两区通信后,尝试给出唯一结果。",
+        "explanation": "若两区都根据本地 version=7 接受名称修改,可能分别写成“白色水杯”和“透明水杯”,并都向用户确认成功。恢复后虽然能检测冲突,却需要额外的取舍或人工确认。采用最终收敛意味着放弃本例跨区域单一修改顺序的承诺。",
+        "expected": "会得到两份冲突的修改;需要合并、选胜者或人工确认,不能声称曾存在跨两区唯一修改顺序。"
+      },
+      {
+        "title": "把展示读取和修改确认分开",
+        "body": "为商品列表与保存价格分别选读取/写入位置。列表允许显示刷新时间,改价仍需非负整数分和有效版本。",
+        "explanation": "商品列表可读较近的副本并显示刷新时间,保存商品修改则访问能保护版本条件的权威边界。一个服务可以让不同操作采用不同一致性策略;价格变更也属于商品修改,price_cents 仍保持非负整数。",
+        "expected": "列表可以接受标明时效的副本结果;确认修改需受保护的权威边界,同一产品的操作可有不同承诺。"
+      },
+      {
+        "title": "解释正常网络下仍有的权衡",
+        "body": "重新接通两区,把每次等待远端复制增加的延迟写到时序图。此时没有断网,再列出仍要付出的时间与资源成本。",
+        "explanation": "网络没有分区时仍要在复制等待、延迟、吞吐和成本之间选择。CAP 是特定模型下的不可能性结论,不是“平时任意选两个”的产品配置表,也不能替代对超时、故障恢复和业务冲突的完整设计。",
+        "expected": "正常网络下复制仍有延迟和成本;CAP 的取舍发生在特定分区条件下,不能当作平时任意选两个的开关。"
+      }
+    ],
+    "checks": [
+      "能用节点仍在运行但彼此收不到消息的例子解释网络分区。",
+      "能说清拒绝一次无法确认的商品修改为何保护版本规则,却牺牲了该请求的可用性。",
+      "能指出最终一致性需要收敛机制,而且不自动包含固定时间上限。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "面试时说 CAP 可以在 C、A、P 中随便选两项。",
+        "cause": "忽略了网络可能分区这一前提,也没有说明严格定义。",
+        "fix": "先说明发生分区,再讨论坚持线性一致性或让隔离节点继续正常回应的代价。"
+      },
+      {
+        "symptom": "两区都确认同一商品版本修改成功,合并后一个用户的修改消失。",
+        "cause": "只考虑数据最终收敛,没有兑现向用户承诺的修改顺序。",
+        "fix": "让权威边界决定版本修改,或明确允许冲突并定义选择和通知规则。"
+      }
+    ],
+    "exercise": {
+      "task": "商品服务的“商品列表”和“按预期版本保存名称”都要跨区域服务。分区时应如何分别处理?",
+      "hint": "先问旧读或并发修改会造成什么后果,再决定能否让每一侧自行作答。",
+      "answer": "商品列表可返回本地较旧数据并说明更新时间,通信恢复后追赶;若要求读己之写,可路由至权威侧或等待目标版本。保存名称应由能维护版本条件的权威事务或协调协议决定,无法确认的一侧暂缓。前者允许旧读,后者牺牲部分写可用性。若两侧都接受修改,则必须承认并处理冲突,不能再承诺同一版本只有一个成功修改。"
+    },
+    "interview": {
+      "question": "如何准确解释 CAP,而不只是背“最多满足两个”?",
+      "answer": "先给出跨节点消息无法送达的分区场景,说明线性一致性要求已完成写入对后续操作的顺序约束,可用性要求非故障节点的每个请求都能完成正常操作。在这个前提下,隔离侧若无法获知新写,就只能牺牲一致性或部分请求可用性。工程选择应针对具体数据和操作,正常网络下还需评估延迟与复制成本。",
+      "followUps": [
+        "CAP 的一致性与 ACID 中的 C 为什么不是同一个定义?",
+        "如果事先把不同商品的写入归属分配给不同区域,会改变什么取舍?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Gilbert 与 Lynch:CAP 原始论文",
+        "url": "https://www.cs.princeton.edu/courses/archive/spr22/cos418/papers/cap.pdf"
+      },
+      {
+        "label": "etcd:API 保证与线性一致读取",
+        "url": "https://etcd.io/docs/v3.5/learning/api_guarantees/"
+      }
+    ],
+    "intro": {
+      "scenario": "东、西两区都在编辑商品 42,突然两区互相联系不上,但各自仍能接到用户请求。",
+      "startingPoint": [
+        "完成第 03 课,知道副本可能暂时落后。",
+        "在纸上建立两份 name=水杯、version=7 的记录;本课不进行断网或数据库故障操作。"
+      ],
+      "outcome": "比较拒绝部分修改与两侧都接受修改的结果,明确商品读写各自的承诺。"
+    }
+  },
+  {
+    "id": "05",
+    "kind": "theory",
+    "group": "数据协同与正确性",
+    "title": "缓存的四类难题:旧值、穿透、击穿与雪崩",
+    "summary": "沿读写时间线定位旧值来源,并计算缓存失效后的数据库压力。",
+    "objectives": [
+      "画出第一次查询和第二次查询的差别",
+      "用交错请求复现旧价格回填",
+      "计算失效后需要限制的回源量"
+    ],
+    "concepts": [
+      {
+        "title": "Cache-aside:查不到时再去资料室",
+        "body": "回看“观察一次缓存未命中”的推演。应用先读缓存,未命中再查数据库并回填;更新业务时先提交数据库,再使相关缓存失效,是常见模式。它减少重复读取,但不自动让两套存储保持强一致,也不能把缓存失败当成业务事实不存在。"
+      },
+      {
+        "title": "穿透、击穿、雪崩是不同压力来源",
+        "body": "回看“按触发条件选择办法”的推演。穿透:反复查询根本不存在的 product_id,每次都落到数据库。击穿:一个极热键失效,许多人同时回源。雪崩:很多键同时过期或缓存整体不可用,回源量突然放大。名称不重要,先识别是无效请求、单个热点还是大面积失效。"
+      },
+      {
+        "title": "合并回源与打散过期",
+        "body": "回看“按触发条件选择办法”的推演。同一个热点未命中时,只允许少量请求负责重建,其余等待或使用允许的旧值,可减轻击穿。让键的过期时间适度分散,可减少同步过期;但缓存集群整体故障时,TTL 抖动无法提供保护,仍要限制回源容量。"
+      },
+      {
+        "title": "旧值可接受到什么程度由业务决定",
+        "body": "回看“推演旧数据被重新写回”的推演。商品列表可暂时显示旧名称和价格,但修改时的权限和预期版本必须由权威存储确认。缓存保存 id、name、description、is_active、price_cents 与版本。TTL 只是失效条件,慢回填和依赖故障仍会扩大可见延迟,不能把“30 秒过期”当作严格新鲜度保证。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "观察一次缓存未命中",
+        "body": "把商品 42 的缓存清空画在纸上,数据库保留 price_cents=1990、version=7。依次推演两次读取,数出数据库访问次数。",
+        "explanation": "用户查询商品 42:缓存没有 → 数据库返回商品名称、price_cents=1990 和 version=7 → 应用回填并响应。第二次查询直接命中。价格统一以整数分表示;缓存与数据库使用同一字段约定。",
+        "expected": "第一次读库并回填,第二次直接用缓存;两次请求通常只需一次回源,前提是缓存未过期且写入成功。"
+      },
+      {
+        "title": "按触发条件选择办法",
+        "body": "分别写三张卡:大量不存在的商品编号、一个热门商品刚过期、大批商品同时过期。为每张卡选择校验/空值缓存、合并查询或错开过期。",
+        "explanation": "不存在的编号可做输入校验、短期空值缓存或布隆过滤器预筛;布隆过滤器可能误判“可能存在”,仍需查库,并要跟随新数据更新。热点失效可合并回源,成批过期可错开时间。不要用一种办法回答所有缓存问题。",
+        "expected": "三个压力来源不同:无效编号反复查库、同一热点并发查库、许多键同时查库,措施应对应触发原因。"
+      },
+      {
+        "title": "推演旧数据被重新写回",
+        "body": "按 A 读到 1990/v7→A 暂停→B 写 2490/v8 并删缓存→A 回填的顺序画时间线,最后查看库和缓存各是什么。",
+        "explanation": "请求 A 读到旧价格 1990 和 version=7 后暂停;请求 B 将价格改为 2490、版本加一并删除缓存;随后 A 把旧快照回填。可见先更新库再删缓存仍有并发窗口。版本条件、可靠失效事件和有限旧值策略可组合,但要说明各自保障范围。",
+        "expected": "数据库是 2490/v8,缓存却可能回到 1990/v7;更新库后删除缓存仍未消除这个交错窗口。"
+      },
+      {
+        "title": "比较删除、更新与双删",
+        "body": "在上一步时间线分别尝试“直接更新缓存”“立即删除”“延迟再删除”,再把 A 的暂停时间继续拉长。",
+        "explanation": "直接更新缓存可能遇到并发写的先后颠倒;删除缓存让下一次读取从数据库重新获取,但会增加回源。“隔一会儿再删一次”只是缩小部分竞态窗口,无法证明所有慢请求都已经结束。强一致业务应采用能明确验证版本或读取权威数据的路径。",
+        "expected": "更新可能乱序,删除需要重新读库,延迟双删仍可遇到更晚的旧回填;没有固定等待能证明所有慢请求已结束。"
+      },
+      {
+        "title": "计算缓存失效后的压力",
+        "body": "用每秒 1000 次查询、95% 命中率先算正常读库量;再把命中率改成 0,算倍数并写出一个保护数据库的上限。",
+        "explanation": "假设查询量为 1,000 次/秒、命中率 95%,正常回源约 50 次/秒。缓存全部失效会把回源需求推向 1,000 次/秒,是原来的 20 倍。应先有回源并发上限、受控预热和可解释降级,再讨论提高命中率。",
+        "expected": "读库需求从约 50 次/秒升至 1000 次/秒,即 20 倍;回源并发与受理能力必须有边界。"
+      }
+    ],
+    "checks": [
+      "能根据请求特征分别判断穿透、击穿和雪崩。",
+      "能复述“旧读取在删除之后回填”的竞态时间线。",
+      "能算出失效前后数据库需求的倍率,并指出数据库未必能承受。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "空值永久缓存后,新建任务一直查不到。",
+        "cause": "不存在也是会变化的状态,空值缓存没有合适的有效期或失效处理。",
+        "fix": "给空值较短有效期,并在创建后处理对应失效;严格写后读走权威数据路径。"
+      },
+      {
+        "symptom": "缓存故障时全部请求直接查库,数据库也故障。",
+        "cause": "把缓存当成可随时移除的优化,没有按冷缓存路径设计容量。",
+        "fix": "限制回源并发与等待,保留必要能力,对不可承接的请求明确拒绝或降级。"
+      }
+    ],
+    "exercise": {
+      "task": "商品服务 每秒有 2,000 次商品详情查询,缓存命中率 98%,数据库稳定承受 200 次/秒。缓存整体故障时,如何避免连带故障?",
+      "hint": "先计算正常回源量,再讨论允许什么请求等待、拒绝或返回旧值。",
+      "answer": "正常回源约 40 次/秒,整体失效时需求会增至 2,000 次/秒,达到数据库稳定能力的 10 倍。应把回源限制在有余量的预算内,按用户和业务优先级分配,减少非必要刷新并返回明确的稍后重试信息;仅对允许旧读的展示数据使用已有旧值。恢复后分批预热、合并热点回源,避免一起重建。权限和修改版本判断不能拿未知或过期缓存值当许可。更大的缓存不能消除整体故障时的这个边界。"
+    },
+    "interview": {
+      "question": "缓存与数据库如何保持一致?",
+      "answer": "先明确哪些操作要求新鲜到什么程度,再选读取和更新策略。Cache-aside 配合提交后失效很常见,但有删除失败和旧请求晚回填的窗口;可以借助版本、可靠失效和对账缩小或修复窗口。对严格授权或不可破坏的规则,应让权威事务边界作决定,不能单凭延迟双删宣称强一致。",
+      "followUps": [
+        "为什么 TTL 加随机值不能处理缓存集群整体故障?",
+        "布隆过滤器能否单独确认一件商品存在?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Microsoft:Cache-aside 与一致性边界",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside"
+      },
+      {
+        "label": "Amazon Builders’ Library:缓存挑战与策略",
+        "url": "https://aws.amazon.com/builders-library/caching-challenges-and-strategies/"
+      }
+    ],
+    "intro": {
+      "scenario": "水杯详情查询变快了,但改价后偶尔仍显示旧价;缓存重启时数据库又突然变忙。",
+      "startingPoint": [
+        "完成第 04 课,已能区分允许旧读的展示与必须保护版本的修改。",
+        "沿用商品 42,价格用整数分,先写下 price_cents=1990、version=7。"
+      ],
+      "outcome": "沿读写时间线定位旧值来源,并计算缓存失效后的数据库压力。"
+    }
+  },
+  {
+    "id": "06",
+    "kind": "theory",
+    "group": "数据协同与正确性",
+    "title": "消息队列:可靠交付、重复与顺序",
+    "summary": "解释早确认和晚确认各自的失败窗口,并为重复、顺序和失败上限制定规则。",
+    "objectives": [
+      "标出每次交接需要什么证据",
+      "比较两种确认时机会产生什么后果",
+      "限定商品修改必须保持顺序的范围"
+    ],
+    "concepts": [
+      {
+        "title": "两次交接,两种确认",
+        "body": "回看“沿消息逐站标出负责者”的推演。生产者将“导出商品清单 42”交给消息代理,发布确认描述代理已按相应配置接受它;消费者处理后再确认,描述该次投递可以结清。发布确认不表示商品清单完成,消费确认何时发送则直接影响未完成工作会不会被遗漏。"
+      },
+      {
+        "title": "至少一次通常意味着必须面对重复",
+        "body": "回看“推演后确认的重复窗口”的推演。在持久化、重试和可恢复等前提成立时,至少一次交付通过重新投递降低遗漏风险,消费者可能见到同一业务请求多次。至多一次倾向于不重复交付,但可能丢失。具体保证还依赖保留时间、复制、确认和故障模型。"
+      },
+      {
+        "title": "交付顺序不等于副作用完成顺序",
+        "body": "回看“给需要有序的对象选择顺序范围”的推演。即使消息 1 比消息 2 先进入队列,两个消费者也可能让消息 2 先做完。许多日志系统只保证同一分区中的顺序,没有跨分区的全局顺序;重试、重投和并行执行还会影响处理顺序。"
+      },
+      {
+        "title": "Exactly-once 必须说明边界",
+        "body": "回看“推演后确认的重复窗口”的推演。Kafka 的事务能力可以在受支持的读、处理、写链路中把输入进度与输出原子提交;这不自动覆盖外部文件存储、通知接口或另一个数据库。外部效果需要目标系统配合事务、幂等或其他协调,不能把代理的保证直接扩展成所有业务效果恰好一次。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "沿消息逐站标出负责者",
+        "body": "为导出任务 42 画 API→发布器→Broker→Worker→文件/任务状态的线路,在每次交接旁写出能查到的证据。",
+        "explanation": "API 保存任务意图,发布器交给 Broker,Worker 取得消息后读取固定商品快照并写出清单,最后写入结果并确认消费。为每段标出证据:业务记录、发布确认、领取记录、结果引用。没有下一段证据时,不能用上一段的成功替代。",
+        "expected": "至少区分业务记录、消息接收确认、成功领取和结果引用;任何前一段成功都不能替代下一段完成。"
+      },
+      {
+        "title": "推演先确认的遗漏窗口",
+        "body": "让 Worker 取到任务后马上确认消息,然后在写清单文件之前崩溃。沿线路标记谁还记得这项工作未完成。",
+        "explanation": "Worker 收到消息后立即确认,随后在导出商品清单之前崩溃。代理已认为交接结束,不再负责重投,这次任务可能永远未执行。提前确认可以减少某些重复,但把丢失风险交给了业务恢复机制。",
+        "expected": "Broker 可能已删除待处理责任;若业务没有独立恢复记录,任务可能遗漏而不是自动重投。"
+      },
+      {
+        "title": "推演后确认的重复窗口",
+        "body": "改为先写好文件再确认,在确认传输中切断连接;让 Broker 重投给另一名 Worker。",
+        "explanation": "Worker 已把商品清单保存成功,消费确认却因连接中断没有到达。代理重投后,另一个 Worker 再次看到任务 42。因此把确认放在成功之后仍需业务去重,不能仅凭“晚确认”推导出执行一次。",
+        "expected": "第二名 Worker 可能再次执行任务 42;晚确认缩小遗漏窗口,却需要按业务编号保护重复效果。"
+      },
+      {
+        "title": "给需要有序的对象选择顺序范围",
+        "body": "对商品 42 画第 7、8 次修改,再加入商品 99 的一次修改。比较“全部串行”和“同商品串行”,并区分最新快照与逐条动作。",
+        "explanation": "同一商品的第 7、8 次修改若要求下游逐条处理,可按 product_id 路由并串行执行,第 7 次完成后再处理第 8 次。失败消息需要明确的修复或跳过规则,不同商品可并行。若下游只维护最新商品快照,可以用版本条件拒绝旧快照;这不能代替必须逐条完成的动作。严格全局排序会让一个失败阻塞更多无关工作。",
+        "expected": "按商品串行能保留局部顺序且不阻塞无关商品;版本过滤只适合最新快照,不能代替必须逐条执行的动作。"
+      },
+      {
+        "title": "说明失败消息何时停止重投",
+        "body": "准备“文件服务短暂不可达”和“消息字段永远无法解析”两张故障卡,为它们写重试次数上限与达到上限后的去向。",
+        "explanation": "临时依赖错误可在预算内重试,无法解析或持续失败的消息应进入可查询的隔离状态或死信队列。死信表示等待诊断或决定,不是问题自动解决;恢复处理仍要保留原业务标识,并考虑顺序和重复效果。",
+        "expected": "临时错误可有界重试;持续失败进入可查询隔离状态,恢复仍需保留业务编号并检查重复和顺序。"
+      }
+    ],
+    "checks": [
+      "能说清 Broker 确认收到了消息,为什么仍不能显示商品清单生成成功。",
+      "能分别画出先确认导致遗漏、后确认导致重复的时间线。",
+      "能把“有序”限定到具体对象、分区及处理环节,而不是全局口号。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "同一条消息重复写入清单文件或发送完成通知。",
+        "cause": "把消息只投递一次当成可靠前提,副作用目标没有去重。",
+        "fix": "用稳定的业务操作标识保护副作用;若目标不支持幂等或查询,超时后保留未知状态,不能盲重试。"
+      },
+      {
+        "symptom": "任务状态从 succeeded 被重投的旧消息改回 running。",
+        "cause": "消费者只照消息写状态,没有验证当前业务版本和合法转换。",
+        "fix": "把消息当作唤醒或事实输入,让持久状态与版本规则决定是否仍可执行。"
+      }
+    ],
+    "exercise": {
+      "task": "同一导出任务有“开始导出”和“取消任务”两条消息,开始先投递却执行更慢。如何避免取消后被迟到结果重新标成成功?",
+      "hint": "不要只要求换一个“有序队列”;还要考虑已在执行中的外部调用。",
+      "answer": "取消应成为持久的业务请求或状态,完成结果发布时原子检查任务是否仍允许成功以及执行版本是否有效;旧执行者不满足条件就不能改写终态。同一任务可按键路由减少乱序,但不能让已经发出的外部调用瞬间消失,因此界面区分“取消申请”和“已取消”。结果文件可先单独保存,最终发布引用受状态规则约束;未采用的临时结果另作清理。代价是额外状态与查询,收益是顺序假设失效时仍保护业务结果。"
+    },
+    "interview": {
+      "question": "怎样保证消息不丢、不重复?",
+      "answer": "应拆成两个问题。交付侧讨论持久化、复制、发布确认、消费确认、失败重投和保留期限;业务侧用稳定标识、事务或目标端幂等来处理重复效果。先确认有遗漏窗口,后确认有重复窗口。所谓恰好一次必须指出覆盖哪些输入、存储和输出,外部系统通常需要额外配合。",
+      "followUps": [
+        "为什么消费者只开一个仍不能保证永远没有重复?",
+        "严格保序时,毒消息应阻塞整个系统还是某个业务键?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "RabbitMQ:可靠性与确认机制",
+        "url": "https://www.rabbitmq.com/docs/reliability"
+      },
+      {
+        "label": "RabbitMQ:队列顺序与重投",
+        "url": "https://www.rabbitmq.com/docs/queues"
+      },
+      {
+        "label": "Apache Kafka:交付语义与外部系统边界",
+        "url": "https://kafka.apache.org/38/design/design/"
+      }
+    ],
+    "intro": {
+      "scenario": "导出消息交给队列后,仍可能漏执行或执行两次;现在沿一条消息找出空隙。",
+      "startingPoint": [
+        "完成第 05 课,并已在任务模块做过商品快照导出。",
+        "在纸面标出任务 42 的记录、消息、执行和结果文件四个位置,不向真实队列注入消息。"
+      ],
+      "outcome": "解释早确认和晚确认各自的失败窗口,并为重复、顺序和失败上限制定规则。"
+    }
+  },
+  {
+    "id": "07",
+    "kind": "theory",
+    "group": "数据协同与正确性",
+    "title": "跨系统事务:本地事务、Outbox 与 Saga",
+    "summary": "用同库待发送记录封住受理丢失窗口,并解释跨系统步骤失败后的补偿边界。",
+    "objectives": [
+      "推演交换两次写入顺序仍会失败的原因",
+      "让任务和发送意图同时存在或同时不存在",
+      "区分重试、撤销入口与恢复剩余步骤"
+    ],
+    "concepts": [
+      {
+        "title": "本地事务只保护同一个事务边界",
+        "body": "回看“推演数据库与消息的双写”的推演。在同一个数据库事务中,可以让商品导出任务和待派发记录一起提交或一起回滚。数据库提交后再调用 Broker 或文件服务,不会自动进入这个事务。程序里的连续两行操作不等于跨系统原子提交。"
+      },
+      {
+        "title": "Outbox:把待发送信件先记在同一本账上",
+        "body": "回看“让业务变化和发送意图一起落账”的推演。在同一事务中保存业务变化和待发送事件;独立发布器反复检查并发送。这样业务提交后,发送意图仍留在数据库里。但发布后标记之前崩溃会重复发送,所以它没有消除消费幂等、派发监测、保留和恢复的工作。"
+      },
+      {
+        "title": "2PC:先询问能否提交,再统一决定",
+        "body": "回看“比较方案而不是评选万能模式”的推演。两阶段提交由协调者让支持协议的参与者先准备,再提交或中止。准备阶段可能占用资源;故障与协调信息不可达可能造成等待。它适用于参与者都支持相关事务协议的边界,普通文件服务 HTTP API 不能仅靠应用约定被纳入。"
+      },
+      {
+        "title": "Saga:一串本地完成的步骤与业务补偿",
+        "body": "回看“用下载入口反例理解补偿”的推演。为解释补偿,假设把登记导出任务、发布下载入口和保存清单文件故意拆成跨服务步骤,且错误地先开放下载、后写文件。文件发布失败时,需撤销入口并标记任务失败。补偿是新的业务操作;已经收到的进度通知不能像数据库事务那样撤回。"
+      },
+      {
+        "title": "中间状态对其他请求仍然可见",
+        "body": "回看“选择补偿和重试的边界”的推演。Saga 各步分别提交,通常没有单库事务的整体隔离,其他请求可能看见下载入口却取不到文件。本例用于识别这种设计代价;实际简单商品服务优先先保存文件,确认就绪后再原子发布结果引用,不要求为讲 Saga 拆分业务。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "推演数据库与消息的双写",
+        "body": "依次推演“先写任务、后发消息”和“先发消息、后写任务”,都在两步之间插入崩溃,记录数据库与队列分别剩什么。",
+        "explanation": "先存任务再发消息:中间崩溃会留下没人执行的任务。先发消息再存任务:Worker 可能先收到消息却查不到任务,或者最终数据库写入失败。简单交换顺序无法让两个独立系统自动原子提交。",
+        "expected": "第一种留下无人派发的任务;第二种留下查不到任务的消息。交换顺序没有形成跨系统原子提交。"
+      },
+      {
+        "title": "让业务变化和发送意图一起落账",
+        "body": "现在把“任务 42”和“需要发送任务 42”的记录写进同一个数据库事务。分别在事务提交前失败与提交后 Broker 暂停时检查两条记录。",
+        "explanation": "创建任务 42 的同一个数据库事务里写入一条“需要派发任务 42”的 Outbox 记录。事务失败时两条都没有;事务成功时即使 Broker 暂停,待发送意图仍然存在。可靠性来自持久意图与持续恢复,不是内存回调。",
+        "expected": "提交前两条都不成立;提交后两条一起保留,Broker 暂停也不会丢掉需要派发的事实。"
+      },
+      {
+        "title": "承认发布器也会遇到未知结果",
+        "body": "让发布器发送成功但来不及标记已发送,或发出后收不到确认。为下一轮扫描写出它知道什么、仍不知道什么。",
+        "explanation": "发布器发出消息但没有收到确认,不知道 Broker 是否已经保存;即使收到确认,也可能来不及标记已发。它通常需要重试,因此消费者应识别相同业务事件。Outbox 的“已发送”状态也不等于任务已经执行完成。",
+        "expected": "发布器可能只能重试,因此同一事件可能到达多次;已发送也只证明派发阶段,不能表示清单已经完成。"
+      },
+      {
+        "title": "用下载入口反例理解补偿",
+        "body": "故意采用一个较差顺序:登记任务→开放下载入口→写文件。让最后一步失败,写下用户会遇到什么,以及要撤销哪项已发生动作。",
+        "explanation": "假设按“登记导出任务 → 发布下载入口 → 保存清单文件”推进。文件发布失败后,撤销已开放的下载入口,再将任务标记为失败。逐步记录成功证据、重复处理方式和恢复状态。这个顺序故意暴露中间状态,并非实战推荐顺序。",
+        "expected": "用户可能看到不能下载的入口;需要撤销入口并更新状态,但外部已经看见过的中间状态不能被抹去。"
+      },
+      {
+        "title": "选择补偿和重试的边界",
+        "body": "继续让“撤销入口”超时,再假设完成通知已经送达。分别列出可重试动作、先核实的结果与无法真正撤回的效果。",
+        "explanation": "撤销入口也可能遇到超时,需保存撤销意图并在预算内重试;文件是否写入未知时先按任务编号核实,避免误删已经发布的文件。进度通知已送达无法撤回,可发送准确的新状态。实际实现优先文件就绪后发布引用,用持久状态恢复剩余步骤。",
+        "expected": "撤销意图需要保留;未知文件结果先按编号核实;已读通知只能更正。实际流程宜先有文件,再发布引用。"
+      },
+      {
+        "title": "比较方案而不是评选万能模式",
+        "body": "分别把“同库两条记录”“多参与者统一提交”“保存派发意图”“跨服务步骤补偿”分成四张需求卡,给每张匹配一种方案并写出成本。",
+        "explanation": "单库事务更简单,适合能放在同一数据边界内的规则;2PC 需要参与者支持并承担协调成本;Outbox 处理业务状态与发送意图的原子记录;Saga 管理跨服务工作流及补偿。它们可以配合使用,但各自没有覆盖全部业务正确性。",
+        "expected": "可分别匹配本地事务、2PC、Outbox、Saga;它们解决的边界不同,可以组合,没有一个自动包办全部正确性。"
+      }
+    ],
+    "checks": [
+      "能指出 Outbox 原子提交的两份记录都在哪里。",
+      "能解释发布器重复发送窗口,以及为什么消费者仍需幂等。",
+      "能给出一个可补偿动作和一个不能真正撤销的外部副作用。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "有了 Outbox 就删除全部任务恢复和积压观测。",
+        "cause": "把持久发送意图误解成自动完成承诺。",
+        "fix": "分别观察待发布、已派发未领取、执行中和终态,明确发布器与恢复流程的持续运行责任。"
+      },
+      {
+        "symptom": "任何失败都逆序回滚,最终预算和外部账单对不上。",
+        "cause": "把 Saga 补偿当作数据库回滚,没有区分已产生费用和未消耗预留。",
+        "fix": "按业务事实定义补偿;未知的外部结果先核实,不可逆效果记录为实际发生。"
+      }
+    ],
+    "exercise": {
+      "task": "教学反例先发布了商品清单下载入口,随后文件保存失败。应如何补偿,以及怎样简化实际流程?",
+      "hint": "把任务状态、入口可见性和文件存在性看成可分别核实的事实。",
+      "answer": "先持久记录撤销入口与失败状态的处理意图,撤销已开放入口并将任务标成失败;补偿失败仍需重试和查询,已送达的通知不会自动撤回。实际商品服务先生成文件,确认文件可读后再用受版本约束的事务发布结果引用;进度通知失败可单独补发。Outbox 记录后续派发意图,Saga 用于解释不得不跨多个本地事务时的补偿边界,两者不能让文件服务自动加入数据库事务。"
+    },
+    "interview": {
+      "question": "Outbox 和 Saga 分别解决什么问题?",
+      "answer": "Outbox 让本地业务变化与待发事件在同一事务里记录,避免提交后发送意图凭空丢失,仍可能重复发布。Saga 用多个本地事务推进跨服务业务,在失败时选择继续或做补偿,通常没有整体事务隔离。二者可组合:Saga 的某一步可通过 Outbox 可靠记录下一步通知。",
+      "followUps": [
+        "2PC 的参与者需要具备什么能力?",
+        "为什么已经送达的导出完成通知不能像数据库事务一样回滚?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "AWS:Transactional outbox 模式",
+        "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html"
+      },
+      {
+        "label": "AWS:Saga 模式",
+        "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/saga-patterns.html"
+      },
+      {
+        "label": "Microsoft:Saga 事务与补偿",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/saga"
+      },
+      {
+        "label": "PostgreSQL:预备事务与两阶段提交",
+        "url": "https://www.postgresql.org/docs/current/sql-prepare-transaction.html"
+      }
+    ],
+    "intro": {
+      "scenario": "数据库写好了任务,队列却没收到消息;把发送顺序换一下能解决吗?",
+      "startingPoint": [
+        "完成第 06 课,能说明消息被确认与商品清单已完成是两件事。",
+        "保留任务 42 的四个位置图,增加一个“待发送记录”框,逐步推演,不改实际库。"
+      ],
+      "outcome": "用同库待发送记录封住受理丢失窗口,并解释跨系统步骤失败后的补偿边界。"
+    }
+  },
+  {
+    "id": "08",
+    "kind": "theory",
+    "group": "数据协同与正确性",
+    "title": "幂等与未知结果:超时以后要不要再来一次",
+    "summary": "为同一意图的重试找回同一任务,并保留外部结果未知时的核实路径。",
+    "objectives": [
+      "区分内容相同与意图相同",
+      "挡住两台 API 同时创建同一任务",
+      "约定超时后查询什么、记录保留多久"
+    ],
+    "concepts": [
+      {
+        "title": "幂等关心重复请求的业务效果",
+        "body": "回看“比较三次外观相同的提交”的推演。重复查询商品通常不会产生新副作用;“将名称设为水杯”和“再创建一件水杯商品”语义不同。同一请求可被多次接收、检查甚至计算,只要受保护的业务效果不额外发生,就可能满足约定的幂等语义。"
+      },
+      {
+        "title": "幂等键代表同一个意图",
+        "body": "回看“比较三次外观相同的提交”的推演。客户端为一次“导出商品清单”意图提供稳定编号,超时重试沿用它。两次有意导出同一批商品应使用不同编号。键还要和用户、操作类型及请求内容关联:同键不同参数不能静默当成原请求成功。"
+      },
+      {
+        "title": "去重记录要和所保护的效果一起考虑",
+        "body": "回看“推演两个副本同时受理”的推演。同一数据库中的唯一键和事务可以原子地创建一次任务并记下去重结果;先查缓存、再执行、最后标记成功会留下并发和崩溃窗口。若效果发生在外部文件服务,数据库去重记录无法独自保证远端只执行一次。"
+      },
+      {
+        "title": "未知不是失败",
+        "body": "回看“把保护延伸到外部调用”的推演。超时只说明调用方在期限内没有获得确定答复。请求可能未到达、正在执行,也可能已完成而响应丢失。结果未知时盲目重试非幂等操作,可能重复创建导出任务、写入文件或发送通知。"
+      },
+      {
+        "title": "幂等也有时间和身份边界",
+        "body": "回看“说明有效期与异常处理”的推演。去重记录过期后,迟到重试可能被视为新请求;应依据业务允许的重试与查询周期定义保留期限。请求编号只用于识别意图,不能替代鉴权,用户也不能凭猜到别人的键读取结果。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "比较三次外观相同的提交",
+        "body": "将首次导出、超时后的同次重试、次日主动新导出排成三张卡。商品列表相同,为它们分配能表达用户意图的编号。",
+        "explanation": "用户第一次点击导出,网络超时后重试,以及次日主动再导出一次,商品编号列表可能完全一致。前两次应共享一个意图编号,第三次通常是新意图。仅对请求正文做哈希去重,会误把用户的第二次真实需求吞掉。",
+        "expected": "前两张使用同一个意图编号,第三张通常用新编号;仅看请求正文相同会误吞新的需求。"
+      },
+      {
+        "title": "定义查询得到的几种状态",
+        "body": "为同一意图画处理中、已完成、明确失败、待确认四个状态,分别写再次收到请求时返回的任务编号或核实动作。",
+        "explanation": "服务可记录处理中、已完成、明确失败或待确认。相同意图仍在处理中时,返回相同任务编号让用户查询;已完成时返回与原请求语义一致的结果。明确失败能否重试,要看该失败是否证明没有留下副作用。",
+        "expected": "处理中返回原任务,完成返回原结果;明确失败与未知结果不能一律当作“重新执行即可”。"
+      },
+      {
+        "title": "推演两个副本同时受理",
+        "body": "让 API-A、API-B 同时读到幂等记录不存在,随后都尝试创建。圈出必须由数据库一次决定胜者的位置。",
+        "explanation": "API-A 和 API-B 同时检查同一个键,都看到“不存在”,随后都创建任务。先查再写没有挡住竞争;需要由权威存储的唯一性与事务等原子机制决定谁创建成功,另一方读取已建立的同一任务。",
+        "expected": "仅先查后写会创建两次;唯一约束与事务使一方创建,另一方读取同一已有任务。"
+      },
+      {
+        "title": "把保护延伸到外部调用",
+        "body": "让文件服务已经写入但响应丢失,本地任务尚未记成功。尝试凭稳定 job_id 与执行版本查询文件,再判断是否重写。",
+        "explanation": "Worker 写入共享文件服务时,使用稳定的任务编号与本次执行版本作为对象标识;服务支持幂等请求或按标识查询时,超时后先核实。如果两种能力都没有,本地锁与数据库去重无法排除“远端已写、本地未记”的窗口。",
+        "expected": "可按对象标识核实时先找回远端结果;没有幂等与查询能力时,本地去重无法排除重复远端写入。"
+      },
+      {
+        "title": "说明有效期与异常处理",
+        "body": "接口承诺 7 天内可重试,记录却在 1 小时后删除。安排第 2 天一次重试,再写出记录保留和超过期限后的处理规则。",
+        "explanation": "假设接口允许用户 7 天内重试,去重记录若 1 小时就删除,会破坏这个承诺。超过约定期限或外部结果持续未知时,需要明确的业务查询、人工确认或对账路径,不能假装永久幂等或强行显示成功。",
+        "expected": "旧重试可能变成新执行,违反原承诺;记录期限必须覆盖承诺,并为持续未知或过期请求提供明确核实路径。"
+      }
+    ],
+    "checks": [
+      "能区分输入相同与业务意图相同。",
+      "能说明超时至少可能对应未执行、执行中、已执行三种情况。",
+      "能指出本地唯一约束保护不了不配合的外部文件写入。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "为了重试每次生成一个新的 request_id,出现多份商品清单。",
+        "cause": "把技术请求编号当成业务幂等键,每次重试都变成新意图。",
+        "fix": "一次业务意图沿用同一个幂等键,技术 trace_id 可以另外记录。"
+      },
+      {
+        "symptom": "记录显示 failed,就立刻重新向文件服务写入。",
+        "cause": "把响应超时归为确定失败,没有保留未知结果。",
+        "fix": "按操作编号查询或使用目标端幂等重试;不能确认的非幂等操作先进入待确认状态。"
+      }
+    ],
+    "exercise": {
+      "task": "用户提交商品清单导出时返回超时,前端准备换一个 UUID 再提交。后端任务实际上已创建,应该怎样修改交互和语义?",
+      "hint": "同一次操作的业务编号要稳定,界面还需要知道自己只是暂时拿不到结果。",
+      "answer": "前端在首次提交前生成并保存本次意图的幂等键,后续重试继续使用该键;后端将用户、操作、键和请求摘要关联,在原子事务中创建一次任务并记录结果。重复提交返回同一任务编号,参数不一致时拒绝冲突,页面显示处理中并查询该任务。技术请求仍可以各有 trace_id 便于排查。服务要明确键保存多久;若后续外部文件服务不支持幂等或查询,任务创建幂等并不能消除远端未知结果,还需独立处理。"
+    },
+    "interview": {
+      "question": "如何设计一个幂等接口?",
+      "answer": "先定义“同一次业务操作”的键及用户作用域,再验证同键请求内容一致,用权威存储的原子机制协调并发,把去重状态与本地业务效果一起提交。重复请求得到可查询且语义一致的结果。最后讨论保留期限、处理中状态、超时未知结果和外部副作用的保护边界。",
+      "followUps": [
+        "为什么不能只用请求正文的哈希作为幂等键?",
+        "去重记录与外部效果不能在同一事务里时怎么办?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Amazon Builders’ Library:安全重试与幂等 API",
+        "url": "https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/"
+      },
+      {
+        "label": "RFC 9110:HTTP 幂等方法语义",
+        "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2"
+      },
+      {
+        "label": "PostgreSQL:唯一约束",
+        "url": "https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS"
+      }
+    ],
+    "intro": {
+      "scenario": "点击导出后浏览器超时了,用户再点一次:应该返回原任务,还是新建任务?",
+      "startingPoint": [
+        "完成第 07 课,已有任务记录与待发送记录的方案。",
+        "准备“首次点击、立即重试、次日再次导出”三张请求卡,商品编号列表保持相同。"
+      ],
+      "outcome": "为同一意图的重试找回同一任务,并保留外部结果未知时的核实路径。"
+    }
+  },
+  {
+    "id": "09",
+    "kind": "theory",
+    "group": "数据协同与正确性",
+    "title": "分布式锁与租约:过期后旧 Worker 为什么还在写",
+    "summary": "用过期与恢复的反例检查接管协议,说明写入端如何拒绝过时结果。",
+    "objectives": [
+      "按秒画出两个执行者重叠运行的窗口",
+      "避免旧执行者删除新执行者的资格",
+      "解释序号保护能保证和不能保证的边界"
+    ],
+    "concepts": [
+      {
+        "title": "租约是有期限的使用资格",
+        "body": "回看“推演租约过期的时间线”的推演。锁服务可以给 Worker 一段时间的独占资格,期限结束后允许其他 Worker 接替,避免崩溃者永远占住资源。但旧 Worker 可能只是暂停或断网,它的程序并不会因为远端键过期就自动消失。"
+      },
+      {
+        "title": "所有者标识避免删掉别人的锁",
+        "body": "回看“保护释放与续租”的推演。释放和续期都要确认仍属于自己,检查与修改还需原子完成。随机所有者标识主要用来区分哪次持有,它不表达新旧先后,不能直接代替 fencing token。"
+      },
+      {
+        "title": "Fencing:资源端识别过时的使用者",
+        "body": "回看“让写入端检查新旧序号”的推演。一种常见设计是在每次接管时得到单调递增的序号。受保护资源原子地记录已接受的最大序号,并拒绝更小序号的写入;这样接受新执行者后,旧执行者的迟到写不能覆盖它。只有把序号传进日志,却不在写入处校验,没有保护作用。"
+      },
+      {
+        "title": "锁、去重和事务解决的不是同一个问题",
+        "body": "回看“先问能不能不用长时间持锁”的推演。锁协调谁可以尝试工作;幂等限制重复请求的额外效果;事务保护一个数据边界内的原子变化。任务终态发布可用持久状态和版本条件保护;外部文件服务若不识别 token 或幂等键,拿了本地锁仍不能保证只产生一份外部文件。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "推演租约过期的时间线",
+        "body": "在时间轴标记 A 获得 30 秒资格、10 秒暂停、31 秒 B 接管、50 秒 A 恢复,圈出两段程序都能执行的时段。",
+        "explanation": "A 取得 30 秒租约,执行到第 10 秒暂停了 40 秒;第 31 秒 B 取得新的租约并开始工作;第 50 秒 A 恢复。现在 A 和 B 的代码都可能在运行。这个反例说明“租约同时只给一个人”与“世界上只有一个执行中的进程”不同。",
+        "expected": "第 50 秒以后 A、B 都可能运行;租约到期没有停止 A 的操作系统进程。"
+      },
+      {
+        "title": "保护释放与续租",
+        "body": "在第 50 秒让 A 删除锁,再问当前锁究竟属于谁;同时标记 A 暂停前已发出的文件写请求。",
+        "explanation": "A 恢复后尝试释放锁,应先原子核对所有者;若当前锁属于 B,就不能删除。A 续租失败后应停止开始新工作,但已经发出去的远端请求可能仍在执行,协作停止不能收回网络中已交付的副作用。",
+        "expected": "不核对所有者会删掉 B 的锁;A 停止新工作也不能收回远端已收到的写入。"
+      },
+      {
+        "title": "让写入端检查新旧序号",
+        "body": "给 A 序号 41、B 序号 42,让结果存储先接受 42,再收到 41。把检查序号和真正写数据画在同一个提交框里。",
+        "explanation": "设 A 的 token 为 41,B 为 42。资源先接受 B 的 42 并记录它,随后拒绝 A 的 41。校验 token、更新最大序号与写入数据必须处于同一个可靠的原子边界,否则检查后仍可能被并发插队。",
+        "expected": "存储记录 42 后应拒绝 41;若检查与写入分离,两个请求仍可能插队破坏保护。"
+      },
+      {
+        "title": "补充 fencing 的精确边界",
+        "body": "交换上一步的到达顺序:资源还没见过 42 就收到过期 A 的 41。只按“拒绝小于已见最大序号”规则判断它会不会被接受。",
+        "explanation": "“只拒绝小于已见最大值”的资源,在还未见过 42 时可能接受 41;它保证新序号已被资源接受后的旧写不能覆盖,不自动证明“租约一过期就禁止任何旧写”。若需要后者,应由权威状态原子验证当前有效持有版本或采用满足该要求的协调协议。",
+        "expected": "41 仍可能被接受;这种 fencing 保护新写不被旧序号覆盖,不自动保证租约一过期就拒绝所有旧写。"
+      },
+      {
+        "title": "先问能不能不用长时间持锁",
+        "body": "若只要求任务终态最多发布一次,尝试允许两名 Worker 都计算,再由数据库按状态和版本决定谁能发布;另圈出文件写入保护。",
+        "explanation": "如果目标只是同一个任务只能发布一个终态,可让数据库根据当前版本和合法状态决定发布是否成功,计算阶段允许出现重复,再限制成本。短事务、唯一约束或按键串行有时比跨多次网络调用持有长锁更清楚,但不能省略外部副作用的单独保护。",
+        "expected": "可以把长计算放到锁外,用短的条件提交保护终态;重复计算成本与外部副作用仍需分别约束。"
+      }
+    ],
+    "checks": [
+      "能说明租约键过期为什么不能终止旧 Worker。",
+      "能区分随机所有者 token 和单调递增 fencing token。",
+      "能指出真正拦截旧写的是资源端的原子校验,而不是客户端自己检查一次。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "把锁时间延长到一天,就认为重复执行已被解决。",
+        "cause": "只延后接替时间,没有处理进程暂停、网络延迟或失效锁。",
+        "fix": "定义租约接管、续期失效和资源写入约束,按业务选择版本检查与幂等。"
+      },
+      {
+        "symptom": "日志里有 fencing token,旧结果仍然覆盖了新结果。",
+        "cause": "目标存储并未在写入时校验并拒绝旧 token,或检查与写入不原子。",
+        "fix": "让被保护的资源执行原子新旧判断;不支持此机制的外部系统需其他可证明的保护。"
+      }
+    ],
+    "exercise": {
+      "task": "A 的 token=17,租约过期后 B 获得 token=18 并成功发布商品清单。A 随后恢复,试图发布旧商品清单并删除锁。应分别怎样处理?",
+      "hint": "结果写入和锁释放是两个资源上的动作,需要各自的判断。",
+      "answer": "结果存储已接受 18,A 的 17 应在原子写入检查处被拒绝,不能覆盖 result_ref 或终态;锁释放也必须核对当前所有者,不能删除 B 的锁。A 的临时输出可被标为未采用并另行清理。若 A 之前已写出文件,这个外部效果不会因结果发布被拒绝而自动消失;可按执行编号识别未采用文件,再安排受控清理。若资源尚未见到 18,仅比较最大已见 token 的 fencing 不能单独证明 A 的任何写都必然被拒绝,应明确是否还需当前租约版本检查。"
+    },
+    "interview": {
+      "question": "分布式锁设置过期时间后,为什么仍可能发生并发写?",
+      "answer": "锁服务回收的是资格,旧客户端可能暂停后恢复,并不知道自己的资格已过期;续租也存在延迟和失败。释放要原子核对所有者,受保护写入要有资源端版本或 fencing 约束。还需说明序号怎样可靠生成、资源何时接受它,以及外部系统能否配合。",
+      "followUps": [
+        "为什么随机 UUID 不能直接用来判断哪个持有者更新?",
+        "fencing 能否阻止已发出的文件写入或进度通知?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Redis:分布式锁、所有者与时限假设",
+        "url": "https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/"
+      },
+      {
+        "label": "Google Research:Chubby 锁服务论文",
+        "url": "https://research.google/pubs/the-chubby-lock-service-for-loosely-coupled-distributed-systems/"
+      }
+    ],
+    "intro": {
+      "scenario": "旧 Worker 卡住后,新 Worker 接管了导出;旧 Worker 又恢复并试图写结果。",
+      "startingPoint": [
+        "完成第 08 课,理解同一任务重复执行需要保护最终效果。",
+        "在纸上画从 0 秒到 60 秒的轴,准备 A、B 两名执行者和一个结果发布位置。"
+      ],
+      "outcome": "用过期与恢复的反例检查接管协议,说明写入端如何拒绝过时结果。"
+    }
+  },
+  {
+    "id": "10",
+    "kind": "theory",
+    "group": "稳定性与系统设计",
+    "title": "超时、重试、熔断与隔离:阻止局部故障扩散",
+    "summary": "计算等待与重试如何放大故障,并用独立预算保住商品查询。",
+    "objectives": [
+      "核算剩余时间还能支持几次尝试",
+      "算出多层重试的最大调用数",
+      "为导出故障设计准确降级和渐进恢复"
+    ],
+    "concepts": [
+      {
+        "title": "超时限制等待,截止时间限制整个过程",
+        "body": "回看“给一项请求安排总预算”的推演。连接、响应读取、排队和整项任务可以有不同预算。单次调用设置 5 秒,不代表三次重试加等待仍会在 5 秒内结束。上游剩余预算应约束下游尝试,并为返回和清理留时间;超时本身不证明远端没有执行。"
+      },
+      {
+        "title": "重试会消耗更多资源",
+        "body": "回看“算出多层重试的放大”的推演。短暂错误可能重试后成功,但下游已经过载时,更多请求会恶化情况。指数退避让后续尝试间隔变大,抖动打散很多客户端的同步重试;仍需要最大尝试次数、总时间和重试流量预算。"
+      },
+      {
+        "title": "熔断:一段时间内减少调用故障依赖",
+        "body": "回看“让熔断和隔离一起保护其余能力”的推演。关闭状态允许请求;失败达到判断条件后进入打开状态,快速拒绝;经过等待后半开,放少量探测判断是否恢复。它不修复依赖,也不清理所有已发出的请求,误判或参数不当会延长不可用时间。"
+      },
+      {
+        "title": "隔离:不同用途不要共用全部救命资源",
+        "body": "回看“让熔断和隔离一起保护其余能力”的推演。把慢文件写入、交互查询和批量清单导出的并发槽位或连接资源分开,一个依赖耗尽自己的配额时,其他能力仍有资源。隔离可能让部分空闲资源暂时无法借用,换来更可控的故障影响范围。"
+      },
+      {
+        "title": "降级必须保持结果诚实",
+        "body": "回看“让熔断和隔离一起保护其余能力”的推演。文件存储不可用时,可明确告诉用户导出暂不可用,并继续提供商品查询和已有任务状态。不能返回不存在的下载链接并标成完成。保留查询、取消等轻量能力,通常比让所有请求长期等待更有价值。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "追踪慢调用占住了什么",
+        "body": "让文件写入从 1 秒变成 20 秒,到达速度不变。为每个未完成请求放一个筹码,标出它占用的连接、内存和执行名额。",
+        "explanation": "商品服务 写入文件存储时,即使 CPU 很空,也可能占着连接、协程、内存和并发槽。延迟从 1 秒升到 20 秒,同样的到达速率会让更多工作同时停留在系统内,其他请求开始排队,最终形成超时。",
+        "expected": "同时停留的工作会增多,连接与并发槽可先耗尽;CPU 低仍可能排队和超时。"
+      },
+      {
+        "title": "给一项请求安排总预算",
+        "body": "给交互操作 6 秒预算,划掉已经读取商品的 2 秒,再尝试放入三次 3 秒的写入及等待。写出何时不应发起下一次。",
+        "explanation": "假设一个交互操作总预算为 6 秒,前置商品读取已用 2 秒,就不能再给文件服务三次各 3 秒的尝试。预算必须包括连接、等待、尝试和退避,并根据剩余时间决定是否值得发起下一次。长任务可转为明确可查询的异步流程。",
+        "expected": "只剩 4 秒,放不下三次 3 秒;连接、退避与池等待也占预算,耗时较长的工作应有可查询的异步状态。"
+      },
+      {
+        "title": "算出多层重试的放大",
+        "body": "画 API 最多尝试 3 次,每次导出服务又最多写文件 3 次的树,数叶子节点。",
+        "explanation": "API 调用导出服务最多尝试 3 次,导出服务每次又调用文件服务最多 3 次,一次用户操作最坏可触发 9 次文件服务请求。再加一层会继续乘。约定由合适的一层主导重试,并让其他层理解重试预算。",
+        "expected": "最坏可出现 9 次文件请求;逐层重试相乘,故障时可能把下游压得更慢。"
+      },
+      {
+        "title": "区分值得重试的错误",
+        "body": "把参数错误、无权限、短暂网络中断、限流、文件写结果未知分成五张卡,为每张选择立即返回、稍后尝试或先查状态。",
+        "explanation": "短暂网络故障可在操作可重试且仍有预算时尝试;参数无效或权限不足,原样再发通常没有意义。限流响应应尊重服务建议的等待时间。文件写入若结果未知,先使用幂等语义或状态查询,不能把所有超时都套进自动重试。",
+        "expected": "参数和权限问题原样重试无益;临时错误要看预算,限流尊重等待,未知写入先用幂等或查询核实。"
+      },
+      {
+        "title": "让熔断和隔离一起保护其余能力",
+        "body": "文件服务继续失败,给导出和商品查询分别划执行名额,再设计暂停调用、少量探测、恢复放量三个阶段。",
+        "explanation": "文件服务 A 连续失败后,熔断器暂缓新调用并少量探测;即使探测仍慢,A 的独立并发上限也防止它占满所有资源。历史查询走自己的预算,界面准确显示导出暂不可用。恢复后渐进放量,避免全部积压同时冲回。",
+        "expected": "导出可明确暂不可用,商品查询保留资源;探测恢复后逐步放量,积压不能一次冲回依赖。"
+      }
+    ],
+    "checks": [
+      "能把一次请求的排队、尝试和退避都纳入总时间预算。",
+      "能算出两层各 3 次尝试的最坏下游请求数为 9。",
+      "能说明熔断减少故障调用,隔离保留其他能力所需资源。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "所有服务都很积极地重试,故障后流量翻了数倍。",
+        "cause": "每层独立重试,既没有总预算,也没有区分错误性质。",
+        "fix": "明确主导重试层,采用有上限的退避与抖动,并限制重试占比。"
+      },
+      {
+        "symptom": "熔断打开后页面仍然卡住。",
+        "cause": "熔断只限制新调用,已有请求或共用资源仍然耗尽。",
+        "fix": "结合单次超时、总截止时间和资源隔离,并定义快速返回的业务结果。"
+      }
+    ],
+    "exercise": {
+      "task": "一个 商品服务 请求总预算 8 秒,预处理已耗时 2 秒。文件服务一次尝试上限 3 秒,两次尝试之间要退避 1 秒,还需 1 秒返回余量。剩余预算够两次完整尝试吗?",
+      "hint": "把所有阶段相加,不要只数真正写入文件的时间。",
+      "answer": "剩余 6 秒,但两次完整尝试加退避与返回余量需要 3+1+3+1=8 秒,因此不够。可只安排一次完整尝试并保留余量,或根据真实延迟和剩余时间缩短后续尝试;不能为了凑次数盲目设置过短超时导致更多失败。若操作可异步,可返回已受理和任务查询,但受理必须符合持久化承诺。文件服务的未知结果还需幂等或核实,剩余时间充足也不是盲目重试的理由。"
+    },
+    "interview": {
+      "question": "重试、熔断、限流和隔离有什么区别?",
+      "answer": "重试提高一次可安全重做操作的成功机会;熔断根据依赖状态减少对故障目标的新调用;限流控制进入或发出的速率;隔离限制某类工作占用的并发或资源。它们从不同方向减少故障扩散,需要统一的时间、容量和业务结果约束,不能相互替代。",
+      "followUps": [
+        "为什么给每层相同超时容易超过用户总预算?",
+        "熔断半开时为什么要限制探测数量?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Amazon Builders’ Library:超时、重试、退避与抖动",
+        "url": "https://d1.awsstatic.com/builderslibrary/pdfs/timeouts-retries-and-backoff-with-jitter.pdf"
+      },
+      {
+        "label": "Microsoft:熔断模式",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker"
+      },
+      {
+        "label": "Microsoft:舱壁隔离模式",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/bulkhead"
+      }
+    ],
+    "intro": {
+      "scenario": "文件存储变慢后,商品查询也开始超时;CPU 并不忙,服务为什么仍被拖住?",
+      "startingPoint": [
+        "完成第 09 课,已区分计算、远端写入和终态发布。",
+        "画出 API → 导出 → 文件服务三段调用,给一次交互请求写下 6 秒总预算。"
+      ],
+      "outcome": "计算等待与重试如何放大故障,并用独立预算保住商品查询。"
+    }
+  },
+  {
+    "id": "11",
+    "kind": "theory",
+    "group": "稳定性与系统设计",
+    "title": "限流、背压与容量:队列只能借时间",
+    "summary": "估算积压、清空时间与受理上限,并解释为何副本扩容会改变本地限流总量。",
+    "objectives": [
+      "把任务量换算成下游调用量",
+      "计算高峰后的净消化速度",
+      "根据最大等待时间决定何时拒绝新受理"
+    ],
+    "concepts": [
+      {
+        "title": "四种上限各管一件事",
+        "body": "回看“估算最简单的执行容量”的推演。速率上限限制一段时间进入多少请求,并发上限限制同时执行多少,队列容量限制多少任务等待,下游配额可能限制文件写入次数或字节吞吐。一次商品导出可能分批写多个文件片段,只在入口数请求未必能保护存储容量。"
+      },
+      {
+        "title": "队列吸收短时突发,不能修复长期超载",
+        "body": "回看“计算积压与消化时间”的推演。像餐厅等位区,队列让客人暂时等待,不会让厨师做菜更快。若持续每秒进入 8 个任务而只能完成 5 个,每秒积压增加 3 个。无限队列最终把内存、磁盘、等待时间或任务有效期耗尽。"
+      },
+      {
+        "title": "背压:把下游忙碌传回上游",
+        "body": "回看“在任务失去价值前决定是否受理”的推演。消费者忙时减少预取,队列满时让生产者有限等待或拒绝,入口据此降低受理。反馈链必须传到真正产生工作的地方,才能阻止积压转移到另一个无界缓冲。用户要知道任务是已受理、待提交还是被拒绝。"
+      },
+      {
+        "title": "常见限流算法各有形状",
+        "body": "回看“把公平性与全局精度写清楚”的推演。固定窗口简单,但窗口交界处可能出现短时突发;滑动窗口让时间边界更平滑但有状态成本;令牌桶允许积攒有限突发额度;漏桶式整形以较平稳速度放出,可能增加等待。选择依据是突发容忍、精度和协调开销。"
+      },
+      {
+        "title": "Little 定律连接平均数量与平均等待",
+        "body": "回看“估算最简单的执行容量”的推演。在统计口径一致、系统长期稳定且平均值存在等条件下,平均在系统中的工作数 L = 有效到达率 λ × 平均停留时间 W。若统计的是整个系统,W 包括排队与执行;若只统计队列,则应使用队列等待时间。持续发散的积压不能直接套成稳定容量承诺。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "先数完整任务会放大多少调用",
+        "body": "每秒到达 10 个导出,每个写文件平均调用 4 次。先算文件请求数,再给每次文件大小加一列,指出还缺哪种容量信息。",
+        "explanation": "每秒 10 个商品导出任务,每个平均发出 4 次文件服务请求,下游平均需求约为每秒 40 次,还未包括重试。若文件服务还限制字节吞吐,需要结合导出文件大小;只数 HTTP 请求不足以估算实际瓶颈。",
+        "expected": "至少需要约 40 次文件请求/秒,重试会继续增加;文件字节吞吐也可能先达到上限。"
+      },
+      {
+        "title": "估算最简单的执行容量",
+        "body": "画 10 个执行槽,每个任务平均占 2 秒,假设没有其他瓶颈。计算满负荷平均每秒能完成多少,并标注假设。",
+        "explanation": "10 个执行槽,每个任务平均占用 2 秒,在持续忙碌且没有其他瓶颈的理想条件下,平均吞吐约 5 个任务/秒。这是估算,不是固定保证;耗时波动、共享数据库和外部配额都会改变结果。",
+        "expected": "估算为每秒 5 个任务;这是理想平均值,实际耗时波动与数据库等共享资源会改变能力。"
+      },
+      {
+        "title": "计算积压与消化时间",
+        "body": "高峰以每秒 8 个到达、5 个完成持续 60 秒;之后每秒仍到达 3 个。分两段计算新增积压与清空时间。",
+        "explanation": "若高峰每秒到达 8 个、处理 5 个,持续 60 秒会多积压约 180 个。高峰结束后如果仍每秒到达 3 个,只剩每秒 2 个的净消化能力,需要约 90 秒清空,不是简单用 180 除以 5。",
+        "expected": "先新增约 180 个;恢复后净消化为每秒 2 个,约需 90 秒,而非 36 秒。"
+      },
+      {
+        "title": "在任务失去价值前决定是否受理",
+        "body": "用户只愿等 30 秒,把当前积压与执行能力换算成等待估计,写出超过时限时的新任务响应与旧任务保护动作。",
+        "explanation": "如果用户允许最多等 30 秒,而当前积压按能力估算已超过这个范围,应限制新受理、减少非必要工作或降级。队列长度之外还要看最老任务年龄;几百个轻任务与几个超长任务不能只按相同数量比较。",
+        "expected": "不能只延长队列掩盖过期;应限制受理或降级,同时看最老年龄与任务耗时分布。"
+      },
+      {
+        "title": "把公平性与全局精度写清楚",
+        "body": "先给每个用户划配额,再让 API 从 1 个副本变成 5 个,每个各限 100 次/秒。计算总上限并选择需要严格共享还是允许近似。",
+        "explanation": "给用户分别设置导出速率和并发,可防止一家占满全部 Worker。若每个 API 副本各限 100 次/秒,增加到 5 个副本可能放行约 500 次/秒;严格全局上限需要共享协调或预分配配额,代价是延迟、复杂度和故障时的取舍。",
+        "expected": "独立本地限制可能总计约 500 次/秒;要严格全局上限需协调或分配额度,也会增加成本和故障取舍。"
+      }
+    ],
+    "checks": [
+      "能区分入口 QPS、文件服务调用数、运行并发和排队数量。",
+      "能正确计算持续超载的积压以及有新流量时的净消化速度。",
+      "能说明稳定平均值公式不能直接保证每个请求的尾延迟。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "API 返回很快,用户的任务却等待几小时。",
+        "cause": "把迅速入队当成系统吞吐提高,没有关注任务总完成时间。",
+        "fix": "以受理到完成的目标约束队列和接入,观察最老等待、完成速率与过期任务。"
+      },
+      {
+        "symptom": "扩容 API 后突破了文件服务总配额。",
+        "cause": "限流计数只在单个实例内,实例增加使总放行量增加。",
+        "fix": "明确限制是局部还是全局,并通过共享协调或有边界的配额分配保护总量。"
+      }
+    ],
+    "exercise": {
+      "task": "队列已有 240 个任务,Worker 平均每秒完成 6 个,新任务仍每秒进入 4 个。多久能清空?若到达率改成每秒 7 个会怎样?",
+      "hint": "清空速度是完成速率减去新到达速率;结果成立的前提是这些平均值保持不变。",
+      "answer": "每秒净减少 6−4=2 个,理想估算需要 240÷2=120 秒清空。若到达率为 7,则每秒净增加 1 个,队列不会自然清空;需要降低受理、提升真实处理能力或减少任务工作量。增加队列长度只延迟容量耗尽。平均计算还忽略长尾、重试和资源争抢,所以不能据此向每个用户承诺恰好 120 秒;应结合任务年龄、分位数和明确的受理规则。"
+    },
+    "interview": {
+      "question": "如何设计一个多用户商品服务的限流与背压?",
+      "answer": "从受保护资源出发,分别预算入口请求、运行并发、等待容量和文件读写吞吐。入口按用户保证公平,下游调用点防止分批导出突破配额,队列满或等待超预算时把反馈传回受理端。说明算法允许多大突发、计数是否全局、故障时如何处理,以及拒绝后的用户交互。",
+      "followUps": [
+        "令牌桶容量与补充速率分别控制什么?",
+        "为什么扩容 Worker 可能反而让整体更慢?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "Microsoft:节流与限流选择",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling"
+      },
+      {
+        "label": "Microsoft:调用速率协调模式",
+        "url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern"
+      },
+      {
+        "label": "Little:排队公式 L = λW 原始论文",
+        "url": "https://pubsonline.informs.org/doi/epdf/10.1287/opre.9.3.383"
+      }
+    ],
+    "intro": {
+      "scenario": "导出任务不断排队,增加队列长度后只是更晚失败;先算清来多少、能做多少。",
+      "startingPoint": [
+        "完成第 10 课,知道等待和重试都会占用资源。",
+        "准备纸笔,统一用任务/秒与秒计算,假设耗时稳定,实际波动另行记录。"
+      ],
+      "outcome": "估算积压、清空时间与受理上限,并解释为何副本扩容会改变本地限流总量。"
+    }
+  },
+  {
+    "id": "12",
+    "kind": "theory",
+    "group": "稳定性与系统设计",
+    "title": "可观测性、容灾与一道完整的系统设计题",
+    "summary": "形成含正常路径、失败恢复、观测证据和恢复顺序的商品导出设计。",
+    "objectives": [
+      "画出从受理到文件下载的最小方案",
+      "把每个失败点连到证据和恢复动作",
+      "说明资源有限时先恢复什么及取舍理由"
+    ],
+    "concepts": [
+      {
+        "title": "三类观测信号分别回答不同问题",
+        "body": "回看“选能说明用户受损的观测数据”的推演。指标说明一段时间内多少、多久、失败多少;日志保留某次状态变化和原因;链路追踪连接一次请求跨组件的耗时。异步任务还需要稳定的 job_id 关联多次投递、多个尝试和多段追踪;它和单次 trace_id 不是同一个概念。"
+      },
+      {
+        "title": "业务 SLO 关注用户有没有拿到价值",
+        "body": "回看“选能说明用户受损的观测数据”的推演。“API 99% 返回成功”不能证明“商品清单 99% 按时完成”。可分别定义受理成功率、排队到开始、受理到完成、取消确认等目标。统计必须说明时间窗口、有效请求分母和未到完成期限的任务,避免漂亮数字掩盖积压。"
+      },
+      {
+        "title": "RTO 与 RPO 是目标,不是自动保证",
+        "body": "回看“写出灾难恢复的依赖顺序”的推演。RTO 表示灾难发生后,业务最多可接受多久恢复;RPO 表示最多可接受丢失多长时间范围的数据变化。例如 RTO 30 分钟、RPO 5 分钟,分别约束恢复时间和恢复点距离,不能互换。实际能否达到取决于整条恢复链。"
+      },
+      {
+        "title": "容灾选项体现成本与恢复速度的取舍",
+        "body": "回看“写出灾难恢复的依赖顺序”的推演。备份恢复通常常驻资源较少,但恢复准备较多;保留核心数据的 pilot light、缩小运行规模的温备、多个站点同时服务,逐步增加常驻能力与协调要求。多地活跃也不能自动解决错误配置、误删、写冲突或外部依赖中断。"
+      },
+      {
+        "title": "系统设计题的核心是解释因果",
+        "body": "回看“用一个明确取舍收束答案”的推演。先明确用户要什么和可接受的等待、损失、成本,再画组件和数据流。每添加一个组件,都应能回答它解决哪个问题、失败会影响谁、如何恢复和付出什么代价。术语数量不能替代这些因果关系。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "把设计题限定到可推演的需求",
+        "body": "写下用户提交商品清单导出后离开、稍后回来查询、下载或取消的四步。先选一组教学用数量、峰值、文件大小和完成时限,标明是假设。",
+        "explanation": "题目:设计商品清单导出,提交后可关闭网页,稍后查询进度和下载,支持取消。商品管理只包含名称、描述、整数价格、上架状态、分类和评价。先确认每次导出商品数量、峰值任务量、文件大小、完成时限与恢复目标;本课只做推演,不要求再拆出新业务。",
+        "expected": "需求应有可计算的输入上限与完成目标,商品字段仍限于已有简单管理范围,不需要增加支付或库存业务。"
+      },
+      {
+        "title": "给出最小完整架构",
+        "body": "用前 11 课的图拼出从客户端到 API、任务库、发布器、队列、Worker、文件的路线;给提交和发布结果两处圈出原子边界。",
+        "explanation": "客户端经负载均衡访问 API;API 校验身份和商品编号,在同一事务保存任务快照、幂等记录与派发意图;发布器送入有界队列;Worker 导出包含 price_cents 的固定快照,把文件保存到共享存储,再按状态和版本发布结果引用。客户端按 job_id 查询进度,完成通知可异步发送。缓存只加速允许旧读的商品查询。",
+        "expected": "方案应保存固定商品快照、意图与派发记录;文件准备好后才发布可查询结果,缓存只服务允许旧读的位置。"
+      },
+      {
+        "title": "把失败放回发生的位置",
+        "body": "分别在入口响应、消息发送、Worker 执行、文件返回四个位置插入失败,为每个位置写出能查询的记录和下一步。",
+        "explanation": "入口超时靠稳定幂等键找回同一任务;Broker 暂停时保留发送意图并限制新受理;Worker 中断由持久状态和接管规则恢复;旧执行者不能覆盖已发布终态;文件服务超时保留未知状态并核实。每个恢复判断都要有数据证据,不能仅靠进程存活。",
+        "expected": "能分别找回同一任务、重发持久意图、按版本接管、核实未知文件结果;仅看到进程活着不足以判定恢复。"
+      },
+      {
+        "title": "选能说明用户受损的观测数据",
+        "body": "假设用户投诉“半小时还没导出”,从表中选择能定位受理、派发、等待、执行和存储阶段的数字及关联编号。",
+        "explanation": "观察受理量、失败率、完成时间分位数、最老待发布年龄、队列等待、外部依赖延迟和文件写入吞吐。用 job_id 找到业务历程,用 message_id 与 attempt 区分投递和执行;具体编号放日志或追踪,避免把每个任务编号都做成指标标签造成高基数。",
+        "expected": "最老等待年龄和完成时间比单纯进程数更贴近用户;job_id/attempt 用于日志关联,不逐任务生成指标标签。"
+      },
+      {
+        "title": "写出灾难恢复的依赖顺序",
+        "body": "假设整个区域失效,将权威数据、文件、密钥配置、入口、派发器和 Worker 排成恢复次序,再写一条防止重复重放的检查。",
+        "explanation": "区域失效后,先确认权威数据恢复点和写入归属,再恢复入口、任务协调与执行能力,重新核对未完成任务及外部副作用。数据库任务记录、商品清单文件、派发记录和密钥配置都要可用,单独恢复一张任务表并不等于服务恢复。异步重放还需防止重复效果。",
+        "expected": "需先确认数据恢复点和写入归属,再恢复服务能力并核对未完成效果;只有任务表而没有文件仍无法下载。"
+      },
+      {
+        "title": "用一个明确取舍收束答案",
+        "body": "资源只能先支撑部分能力,选择商品查询、任务查询或批量导出的恢复顺序,并声明可接受旧读、损失与等待边界。",
+        "explanation": "商品列表允许短暂旧读,可以使用副本与缓存;商品修改和导出终态发布由权威事务与版本条件保护。资源有限时优先恢复商品和任务查询,批量导出稍后恢复。说明目标、限制和升级条件,避免同时承诺零故障、零损失与零等待。",
+        "expected": "可以先恢复查询、稍后恢复导出,但必须说明目标和限制;不能同时无条件承诺零损失、零等待、零故障。"
+      }
+    ],
+    "checks": [
+      "能分别用指标、日志和追踪回答“影响多大”“哪项任务”“慢在哪段”。",
+      "能正确解释 RTO 与 RPO,并指出数据库恢复不等于全业务恢复。",
+      "能按需求→容量→架构→数据规则→故障恢复→取舍讲完一道设计题。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "监控全绿,但用户商品清单一直没有生成。",
+        "cause": "只监控进程心跳与接口 200,没有监控异步任务完成情况。",
+        "fix": "增加业务完成率、最老等待和各阶段持续时间,按用户目标解释异常。"
+      },
+      {
+        "symptom": "恢复数据库后直接重放全部任务,重复生成文件或发送完成通知。",
+        "cause": "没有确认哪些外部效果已经发生,也没有保留稳定任务编号和接管边界。",
+        "fix": "按任务状态与文件、通知证据分类恢复,未知结果先核实,重放仍受幂等与版本规则约束。"
+      },
+      {
+        "symptom": "写了 RPO=0,就认为任何故障都不会丢数据。",
+        "cause": "把期望目标当成已经实现的保证,没有定义故障模型和确认边界。",
+        "fix": "说明何时向用户确认、哪些副本持久化、相关对象如何保持可恢复,并区分设计目标与实际证据。"
+      }
+    ],
+    "exercise": {
+      "task": "用不超过六段话设计可恢复的商品清单导出服务。峰值 20 个任务/秒,每个任务平均 3 次文件服务调用,文件服务配额为 50 次/秒;允许有限排队,RTO 为 30 分钟,RPO 为 5 分钟。",
+      "hint": "先找容量矛盾,再解释受理、持久状态、执行、观测和恢复,所有数字都是本题假设。",
+      "answer": "第一段:用户需要商品清单导出、状态查询、文件下载和取消,明确排队上限及允许 5 分钟恢复点差距的数据范围。第二段:下游需求为 20×3=60 次/秒,超过 50 且未计重试,理想持续能力上限约 16.7 个任务/秒,必须限流、减少文件调用、提高配额或只承接有限突发。第三段:API 同一事务保存固定商品快照、幂等任务与 Outbox,发布器送有界队列,Worker 受控执行并按版本发布文件引用。第四段:重投沿用 job_id,文件未知结果按对象标识核实;取消与旧执行者发布均受持久状态约束。第五段:观察受理到完成时间、最老等待、派发积压和依赖错误,用 job_id 关联日志与追踪。第六段:按预算选择备份或温备,恢复同时考虑数据库、商品清单文件、配置和未完成通知,以 30 分钟内恢复约定能力为目标;5 分钟 RPO 不允许任务无声消失,应识别受影响提交并恢复可查询状态。更严目标需要额外复制与协调成本。"
+    },
+    "interview": {
+      "question": "面试中如何设计一个支持可靠导出的高可用商品服务?",
+      "answer": "先明确商品操作、导出量、文件大小、完成时限和一致性要求,再估算数据库与文件存储需求。画入口、商品与任务状态、队列、执行器和结果存储,沿请求解释受理与完成。补充幂等、未知结果、租约接管、限流和业务观测,最后用 RTO/RPO 与故障范围说明恢复能力和成本取舍。",
+      "followUps": [
+        "文件存储整体不可用时,哪些商品服务能力仍可提供?",
+        "如何避免恢复区域与旧区域同时发布结果?",
+        "为什么 job_id 不适合作为常规指标的无限增长标签?"
+      ]
+    },
+    "refs": [
+      {
+        "label": "OpenTelemetry:可观测性基础",
+        "url": "https://opentelemetry.io/docs/concepts/observability-primer/"
+      },
+      {
+        "label": "Google SRE:分布式系统监控",
+        "url": "https://sre.google/sre-book/monitoring-distributed-systems/"
+      },
+      {
+        "label": "AWS:RTO 与 RPO 目标",
+        "url": "https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/disaster-recovery-dr-objectives.html"
+      },
+      {
+        "label": "AWS:云端灾难恢复方案",
+        "url": "https://docs.aws.amazon.com/whitepapers/latest/disaster-recovery-workloads-on-aws/disaster-recovery-options-in-the-cloud.html"
+      }
+    ],
+    "intro": {
+      "scenario": "现在为同一商品清单导出交一份完整设计:用户能离开页面,回来查看进度并下载结果。",
+      "startingPoint": [
+        "完成第 01—11 课,保留边界图、失败时间线和容量计算。",
+        "只使用名称、描述、整数价格、上下架、分类与评价这些既有商品字段;本课只做设计推演。"
+      ],
+      "outcome": "形成含正常路径、失败恢复、观测证据和恢复顺序的商品导出设计。"
+    }
+  }
+]

Dosya farkı çok büyük olduğundan ihmal edildi
+ 362 - 178
public/content/docker.json


Dosya farkı çok büyük olduğundan ihmal edildi
+ 357 - 185
public/content/linux.json


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 145
public/content/redis.json


+ 1896 - 0
public/content/review.json

@@ -0,0 +1,1896 @@
+[
+  {
+    "kind": "review",
+    "id": "01",
+    "group": "接口与数据",
+    "title": "API 服务开发:我真的掌握了吗",
+    "summary": "从一次商品请求讲清运行、校验、状态码与等待,再用独立操作证明商品接口、进度流和连接停止符合约定。",
+    "sourceModule": "api",
+    "intro": {
+      "scenario": "一位同学能照着视频点出商品列表,却解释不了重启后商品为何消失,也分不清 422 和服务未启动。现在由你接手这份本机小目录:先闭卷回答,再用自己的请求与观察证明理解。",
+      "startingPoint": [
+        "已完成 API 服务开发模块,保留 api-lab、对应虚拟环境及可运行的 main.py;先确认 /health 可访问,所有变式只使用本机课堂数据。",
+        "每题先独立写下预测、理由和验证办法,再展开参考要点;实战题先做并记录证据,卡住时再回看指定课时。",
+        "自评用于发现薄弱处。能认出参考答案不等于能独立完成;没有实际执行的操作明确记为未验证。"
+      ],
+      "outcome": "交出自己的解释、请求结果与故障定位记录,指出哪些能力已有独立证据、哪些仍需回课练习。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "文件还在,为什么商品却没了?",
+        "prompt": "服务启动后访问根地址得到 404,而 /health 正常;创建一件陶瓷杯后保存 main.py,服务重载,商品列表又变空。请分别解释这两个现象,并说明如何核对服务确实用了 api-lab 的 Python。",
+        "checkpoints": [
+          "/health 正常说明进程能够回应;根路径没有注册路由时返回 404,与端口连接失败不同,不能仅凭根路径判断服务没启动。",
+          "路由由方法和路径共同匹配到函数;main:app 指向 main.py 中的 app,磁盘上存在文件本身并不等于服务进程正在运行。",
+          "本课商品字典只在当前进程内存在,自动重载重新启动应用会清空它;Python 源文件保留不能证明商品数据持久保存。",
+          "读取实际解释器路径,确认位于 api-lab/.venv,再核对安装和启动使用同一个 Python;提示符出现环境名只是辅助线索。"
+        ],
+        "misconception": "把所有 404 都归因于服务没启动,或认为源码没删所以商品不会丢。前者混淆路由结果与连接失败,后者混淆磁盘代码与进程内数据。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "启动第一个 FastAPI 服务"
+          },
+          {
+            "lessonId": "06",
+            "label": "完成内存版商品 CRUD"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "三次填错,应该改地址还是改商品字段?",
+        "prompt": "读取 /products/abc、列表条件 limit=0、创建商品时填写空名称,分别应从哪里找到错误位置?再解释:正确输入却因服务端漏返回 id 得到 500,为什么不能要求调用方换个商品名称重试?",
+        "checkpoints": [
+          "非法编号、越界列表数量、空商品名都可触发 422,但错误位置分别指向 path.product_id、query.limit 和 body.name;必须阅读位置,不能只看相同状态码。",
+          "路径与查询字符串先是文本,声明的类型和范围在路由执行前参与校验;ProductCreate 则说明 JSON 商品字段及默认值。",
+          "价格按整数分接收,输入必须符合该字段的整数、非负和上限要求;省略字段、显式 null 与提交一个合法值不能一概当成相同输入。",
+          "ProductOut 要求返回 id,正常请求因服务返回数据缺字段而失败属于服务端输出错误;response_model 还筛选公开字段,不能用修改正确输入掩盖返回代码的问题。"
+        ],
+        "misconception": "认为只要出现字段问题就一定是调用方 422,或者只在路由函数里捕获所有校验失败。输出错误应修服务端,部分输入失败还发生在路由执行之前。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "从路径、查询参数和 JSON 中接收数据"
+          },
+          {
+            "lessonId": "04",
+            "label": "用 Pydantic 定义输入和输出契约"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "重复替换与重复创建,结果应一样吗?",
+        "prompt": "先创建帆布袋,再对返回的编号执行两次完全相同的 PUT,随后执行两次 DELETE。请预测各阶段的结果和最终状态,并解释为什么把创建请求原样重发两次可能得到两件商品。",
+        "checkpoints": [
+          "创建成功应得到 201 和实际 id,后续请求使用这个 id,而不是固定假定为 1;再次 GET 才能核实保存结果。",
+          "本模块 PUT 是完整替换,name、description、is_active、price_cents 四个可写字段必须明确提交;两次相同 PUT 后内容一致且 id 不变。",
+          "第一次 DELETE 成功返回 204 且响应体为空,之后读取为 404;再次删除可以返回 404,但目标始终是不存在,幂等关注最终状态而非响应逐字相同。",
+          "本模块普通 POST 没有自动去重协议,重复创建会分别分配编号;不能把 PUT 的重复行为推广到 POST。验收期间不改代码触发内存重载。"
+        ],
+        "misconception": "认为幂等要求每次状态码完全一样,或拿创建模型的默认值模拟局部更新。前者忽略最终状态,后者会把未提交字段重置而不是保留旧值。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "设计商品资源与 HTTP 行为"
+          },
+          {
+            "lessonId": "06",
+            "label": "完成内存版商品 CRUD"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "私有入口为什么没有按预期出现或放行?",
+        "prompt": "你把 /private 写进 routers/private.py 后,文档里看不到它。接入后,请求分别得到 503、401 和 200。请给出检查顺序,并说明为什么统一错误格式时仍要保留原有状态码和响应头。",
+        "checkpoints": [
+          "先确认主入口在创建 app 后执行 include_router;仅导入模块或保存文件不会自动将 APIRouter 里的接口加入应用,也不能再新建一个 app 覆盖旧路由。",
+          "503 可对应服务没有配置 DEMO_API_KEY;在启动服务的同一个终端设置后重新启动,再验证进程读取的配置,另一窗口改变量不会改变旧进程。",
+          "依赖从 X-API-Key 请求头取值,缺失或错误时在路由之前返回 401,正确课堂值才放行到 200;把值放进查询参数不能满足 Header 声明。",
+          "统一 error 外层仍保留 HTTP 状态及 Allow、WWW-Authenticate 等必要头。固定错误结构方便调用方定位,不应把失败都变成 200,也不意味着形成完整用户身份系统。"
+        ],
+        "misconception": "把依赖当成只供文档展示的注释,或通过放宽全部路由来消除错误。依赖实际控制请求是否进入函数,注册、服务配置和来访者输入要分别检查。",
+        "revisit": [
+          {
+            "lessonId": "07",
+            "label": "返回可定位、格式一致的错误"
+          },
+          {
+            "lessonId": "08",
+            "label": "复用依赖与拆分路由文件"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "两个慢请求与一个浏览器错误,是否同一原因?",
+        "prompt": "两个各等 1 秒的请求有时总计约 1 秒,有时约 2 秒且拖慢 /health。另一个商品网页用 localhost 打开时失败,但 curl 能成功。请分别提出最小对照实验,说明每个实验能证明什么。",
+        "checkpoints": [
+          "保持同一单进程服务、相同并发客户端和相同 delay,只比较 await asyncio.sleep 与在 async 路由内直接 time.sleep;前者等待可重叠,后者会占住事件循环。",
+          "同时观察 /health 与总耗时,记录环境和合理开销;async 关键字本身不会把阻塞函数变成异步,也不能据此宣称大量 CPU 计算会加速。",
+          "浏览器实验比较完整 Origin:协议、主机和端口;localhost 与 127.0.0.1 是不同来源,查看 OPTIONS 预检和授权响应头,而不是先修改商品内容。",
+          "curl 与服务端 HTTPX 不执行浏览器 CORS 限制。HTTPX 调用上游超时是另一段链路,本课可返回 504,不能把它与浏览器来源不匹配合并诊断。"
+        ],
+        "misconception": "认为“curl 成功所以网页一定能读响应”,或“写了 async 所有等待就会并行”。前者漏了浏览器检查,后者漏了实际执行的阻塞调用。",
+        "revisit": [
+          {
+            "lessonId": "09",
+            "label": "使用 async / await 处理 I/O 等待"
+          },
+          {
+            "lessonId": "10",
+            "label": "使用 HTTPX 调用服务与调试 CORS"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "清单进度应该什么时候显示、什么时候结束?",
+        "prompt": "商品保存只需一次结果,清单进度要逐段显示,咨询消息需要双方继续发送。请给三种需要各选一种本课通信方式;再解释为什么对 SSE 响应调用完整文本读取,或将每个网络块直接当成一条 JSON,都可能让体验出错。",
+        "checkpoints": [
+          "普通商品写入可用 HTTP 请求与完整响应;单向持续进度可用 SSE;连接中双方都要主动发消息时可用 WebSocket。只是偶尔查状态时,定期 HTTP 查询也可能足够。",
+          "SSE 在同一个 HTTP 响应体里按空行划分事件;本课 token 表示文字片段,done 表示正常完成,不能把连接消失直接当成业务完成。",
+          "完整文本或 JSON 读取要等响应体结束;手动读取应连续解码 UTF-8、缓存未完成文本再按事件边界提取,因为字符和事件都可能跨块,一块也可能含多条事件。",
+          "EventSource 适合本课 GET 并在 done 后主动关闭;需要 POST 请求体时可用 fetch 并自行解析。主动取消应有明确停止动作和取消结果,不当作一般服务故障。"
+        ],
+        "misconception": "把一次 read 与一条完整事件画等号,或因为 SSE 使用 HTTP 就认为它不能逐段显示。网络分块和业务事件边界并不重合,读取方法也会决定何时向用户展示。",
+        "revisit": [
+          {
+            "lessonId": "11",
+            "label": "完整响应、流式响应、SSE 与 WebSocket"
+          },
+          {
+            "lessonId": "13",
+            "label": "在浏览器中读取流,并在中途停止"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "独立验收一件新商品的完整生命周期",
+        "prompt": "不复制练习答案,为本机目录增加一件“旅行收纳袋”,价格 2590 分,先不上架。独立完成创建、读取、完整替换、筛选和删除,并加入一次非法价格输入。先写出验收顺序及预期,再执行并保存请求记录。",
+        "checkpoints": [
+          "从创建响应取得实际 id,记录 201,再读取同一商品确认名称、描述、上架状态和整数价格;不依赖现有样品编号,也不在过程中重载服务。",
+          "完整替换明确提交四个可写字段,把价格改为 2790 分并上架;再次读取核实 id 不变且四项内容符合新值,同时比较明确 false 筛选与未传筛选的差异。",
+          "发送负价格等确定不符合契约的输入,记录 422 和具体字段位置,并重新读取原商品证明失败请求没有改掉合法数据。",
+          "删除后核对 204 空体,再次读取得到 404;把核心状态和结果写成可重复断言,说明哪些是实际记录,哪些还未执行。"
+        ],
+        "misconception": "只保存几张成功响应,便说完整流程已经验证。没有后续读取、失败后的数据检查和删除空体检查,无法证明各操作之间的真实状态变化。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "完成内存版商品 CRUD"
+          },
+          {
+            "lessonId": "16",
+            "label": "把能运行的接口变成可重复验收的服务"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "连接恢复了,停止按钮还有效吗?",
+        "prompt": "使用本机咨询回显服务,独立安排“正常收发、服务中断后恢复、用户主动停止”三段实验。同时检查清单流的完整内容与真实逐段到达。先写出每一段要记录的证据,再操作;不要将自评勾选当作自动测试通过。",
+        "checkpoints": [
+          "正常连接后先等 OPEN 再发咨询和 ping,记录 echo 与 pong;关闭并恢复服务后观察有上限、逐渐延长的重试以及新连接的真实回信。",
+          "在下一次重试前主动停止,等待超过本课最长重试间隔并检查没有新增连接;断线时未发送的业务消息不应在重连后自动出现。",
+          "内容测试检查清单 token 拼接结果与完整响应一致且 done 最后出现;真实网络另记首段与后续片段到达时间,不能用 TestClient 的完整结果证明延迟表现。",
+          "分别记录正常完成、主动取消和异常断开;解释心跳是本课应用层消息、重连是新连接,恢复通道并不自动恢复消息执行状态。"
+        ],
+        "misconception": "看到重新出现“已连接”就宣布恢复与停止都可靠。新连接不证明旧消息已送达,停止后还有计时器也可能继续重连,必须验证可观察的后续行为。",
+        "revisit": [
+          {
+            "lessonId": "15",
+            "label": "连接断开后:重连、心跳与取消"
+          },
+          {
+            "lessonId": "16",
+            "label": "把能运行的接口变成可重复验收的服务"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立解释一次商品请求从地址匹配、输入检查到响应生成的过程。",
+        "evidence": "不看要点写出 404、405、422、500 的不同触发位置,并为其中至少两项提供自己复现的状态与错误位置。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立启动正确环境并完成一件商品的创建、替换和删除。",
+        "evidence": "交出 .venv 解释器路径、真实商品 id 以及操作后的读取结果,说明重载对内存商品的影响。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立区分内容检查与真实网络、浏览器行为验证。",
+        "evidence": "交出一组可重复断言和一次逐段到达时间记录,明确指出 TestClient 未覆盖的浏览器检查。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立为等待、来源错误和连接中断提出有依据的排查步骤。",
+        "evidence": "复现一个受控故障,保留对照结果、修复后恢复请求及停止后无新增连接的记录;未执行部分如实标记。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "02",
+    "group": "接口与数据",
+    "title": "数据库基础:我能设计并验证商品数据吗",
+    "summary": "从字段和关系设计开始,用合法与非法写入验证规则,再解释事务、并发、索引、连接与恢复的实际边界。",
+    "sourceModule": "database",
+    "intro": {
+      "scenario": "你要为一个只管理商品名称、价格、上架状态与分类的小目录设计数据库。同学说“表建出来、查询不报错就算学会”,你需要用具体反例、执行结果和恢复证据检查这个判断。",
+      "startingPoint": [
+        "已完成数据库基础模块,保留 shop-service 与本机 shop_lab;确认练习账号、库名和当前客户端位置,不把本机实验指向业务数据。",
+        "每题先独立给出设计或预测,再展开要点对照。操作题使用新建课堂样品并记录实际编号,不复制固定 id,不省略失败后的数据核查。",
+        "结构修改、双会话和恢复各有已学前提;开始操作前重查对应课时的实验边界。没有执行的项写未验证,自评不代表客观考试结论。"
+      ],
+      "outcome": "交出商品字段与关系设计、有效和无效写入结果、并发观察及独立恢复记录,按证据判断自己能否脱离示例完成。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "字段设计能挡住哪些具体错误?",
+        "prompt": "一个保温杯可以改名,也允许和其他商品同名;售价 19.90 元,新建时默认下架,免费样品售价为 0。请独立设计商品核心字段,说明类型、主键、非空、默认值与检查约束分别在防什么错误,并说明如何保存创建和修改时间。",
+        "checkpoints": [
+          "用稳定 id 主键识别商品,名称使用有长度限制的文本且允许重名;名称会变,不适合当身份,也不能用自增编号大小当商品数量。",
+          "price_cents 用有范围的整数分,1990 表示 19.90 元,0 表示免费而非未知;NOT NULL 与非负 CHECK 各自保留,应用还应验证整数与范围。",
+          "is_active 默认 FALSE,并用约束限制 0/1;名称应非空白,描述有明确空串语义与 16000 字符上限,不能以“数据库接受文本”为由忽略业务边界。",
+          "DATETIME(6) 按统一 UTC 约定保存,字段本身不带时区;created_at 保持初次值,修改时显式更新 updated_at 并递增 version,默认值不会自动代劳。"
+        ],
+        "misconception": "把所有列统一做成可空文本,或认为 CHECK 足以替代 NOT NULL。这样无法表达数值和缺值语义;CHECK 对 NULL 得到的 UNKNOWN 也不能承担非空检查。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "从商品需求设计表结构、字段与数据约束"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "一个商品属于两个分类,怎样避免重复与孤儿?",
+        "prompt": "桌面收纳盒同时归入“办公”和“桌面”,另一件样品暂时没有分类。请画出三张表的关系,决定主外键和两个查询方向的索引,再预测重复归类、删除被引用分类、下架商品、删除商品的结果。",
+        "checkpoints": [
+          "products 与 categories 各自保存商品和分类事实,product_categories 一行保存一组归类;两个外键的类型、符号与父表主键一致,不复制分类名或存逗号编号串。",
+          "关联表以 product_id、category_id 组成联合主键,拒绝重复归类;以 category_id、product_id 建反向访问路径,分别支持两个查询方向。",
+          "分类名按本课规则唯一,默认 ai_ci 比较下只变大小写仍可能冲突;商品名允许重名,不能把两种名称的唯一性规则混为一谈。",
+          "下架保留归类;删除商品由 CASCADE 清理关联但保留分类;删除被引用分类由 RESTRICT 拒绝。未分类商品用 LEFT JOIN 保留,统计关联列而非全部连接行才能得到 0。"
+        ],
+        "misconception": "给关系表随便加一个 id 后就允许重复组合,或把下架等同于删除。多一个编号不能代替组合唯一规则,状态修改也不会自动触发删除级联。",
+        "revisit": [
+          {
+            "lessonId": "04",
+            "label": "设计商品分类关系表、主外键与删除策略"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "第二条写入失败,第一件商品还在吗?",
+        "prompt": "Python 创建一件名称含单引号的技术书,再给它关联一个不存在的分类。请说明如何传入名称,如何获得新编号,以及第二次写入报错后应怎样结束事务。另解释为什么不能把一次建表语句顺手夹进这组业务写入。",
+        "checkpoints": [
+          "通过驱动参数传商品名,使单引号和看似 SQL 的片段保持为普通值;不使用字符串拼接生成含用户内容的 SQL,列名等标识符另用固定白名单。",
+          "从当前连接的插入结果取得生成编号,再按这个 id 查询或归类;不用 MAX(id) 猜刚创建的是哪一条,也不把执行行数当作记录内容。",
+          "若商品与分类必须同时成功,外层控制同一事务,异常后整体 rollback,最后关闭或归还连接;外键失败通常只撤销失败语句,不能因还能 SELECT 就继续提交。",
+          "CREATE、ALTER 等结构操作有隐式提交边界,不能用普通 ROLLBACK 假定全部撤销;若分类只是可选步骤,须先明确业务规则才考虑保存点。"
+        ],
+        "misconception": "认为数据库一报错误就已经替你回滚全部业务,或 with 游标退出等于提交。错误范围和客户端资源范围不同,必须主动处理事务结果。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "用PyMySQL参数化完成增删改查"
+          },
+          {
+            "lessonId": "05",
+            "label": "明确InnoDB事务、保存点与DDL提交边界"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "另一个人已保存,为什么我还看到旧值?",
+        "prompt": "A、B 同时读取同一商品,B 把描述改为 v2 并提交。请预测 A 在 RR、RC 下继续普通查询的不同结果,再解释 RR 的 FOR UPDATE 为什么可能看到新值。若两位编辑都带着 version=1 保存,怎样拒绝后来的覆盖?",
+        "checkpoints": [
+          "RR 的普通一致性读通常沿用事务首次一致性读建立的快照,因此 A 可能仍见旧值;RC 每条一致性读可见期间新提交的值,两者都不读另一事务未提交数据。",
+          "FOR UPDATE 使用锁定的当前读规则,不能套用普通查询的旧快照结论;它会取得锁,要及时结束短事务,不在用户编辑页面期间持有。",
+          "把旧版本比较、字段更新和版本递增放在同一条条件更新里;首个 version=1 更新成功后版本变为 2,第二个旧版本匹配 0 行,不应宣称保存成功。",
+          "锁等待超时与死锁不是相同事件,回滚范围也可能不同;操作后结束失败事务,再核对最终描述、版本以及是否还有活动事务。"
+        ],
+        "misconception": "认为 RR 会自动给所有旧页面保存返回版本冲突,或者先读版本再无条件更新就没有竞争。读取快照不替代原子版本条件,分开的检查与写入之间仍能插入另一修改。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "用双会话观察RR、RC与当前读"
+          },
+          {
+            "lessonId": "07",
+            "label": "观察行锁、间隙锁、死锁与版本冲突"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "只返回 20 行,为何还是读了很多数据?",
+        "prompt": "上架商品按创建时间和 id 倒序显示,每页 20 行。请提出组合索引候选,并说明如何从计划判断其作用。翻到很深的位置时,为什么 OFFSET 的工作量可能很大?已有上一页末行后,怎样确定下一段范围?",
+        "checkpoints": [
+          "候选索引围绕等值上架条件和倒序创建时间、id 组织,详情 id 已有主键;索引来自固定查询,不是每个字段都顺手添加。",
+          "记录数据量、上架比例和同一查询的前后计划,比较所选 key、扫描行数与排序情况;EXPLAIN ANALYZE 实际执行 SELECT,不能把估计 cost 当毫秒。",
+          "OFFSET 即使只输出 20 行,通常仍处理之前被跳过的大量记录;应查看底层节点而不仅看顶层返回数量。",
+          "按时间更早或同时间 id 更小的范围继续读取,保留相同筛选和排序;比较性能时单列获取深游标的成本,实际计划仍需验证。索引也增加写入维护和空间成本。"
+        ],
+        "misconception": "看到索引名字就宣布优化完成,或藏起取得第 40000 行游标的成本。优化需要同条件下的实际工作量对照,查询结果少不等于扫描工作少。",
+        "revisit": [
+          {
+            "lessonId": "08",
+            "label": "设计组合索引、覆盖索引与JSON路径索引"
+          },
+          {
+            "lessonId": "09",
+            "label": "读懂EXPLAIN ANALYZE并优化深分页"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "借不到连接,换 root 能解决吗?",
+        "prompt": "两个请求占住小池中的两条连接,第三个请求借用超时。同学建议改成无限连接并让商品程序使用 root。请指出该建议混淆了什么,说明怎样验证连接归还、事务结束和日常账号权限。",
+        "checkpoints": [
+          "池耗尽是应用等待空闲连接超时,与数据库行锁等待或认证失败不同;先核对借还范围、并发量和查询耗时,不能用换账号掩盖。",
+          "每个并发任务用自己的连接和短事务,成功提交、失败回滚,最后归还;池代理 close 可保留底层连接复用,看到 Sleep 不能单独证明泄漏。",
+          "验证释放占用后新请求又能借到连接并查询,容量设置有上限与等待预算;不能通过跨线程共用同一活动事务或无限扩容来绕过。",
+          "日常账号保留本课商品读写权限,结构维护使用独立身份;实际检查 CURRENT_USER 与授权,做一项允许的写入和一项预期被拒的 ALTER,拒绝不应被当作待修故障。"
+        ],
+        "misconception": "把容量、锁与权限都归为“数据库连不上”。root 无法修复遗留事务,无限连接还会转移压力;应按失败发生层次寻找证据。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "用有界连接池管理PyMySQL连接与事务"
+          },
+          {
+            "lessonId": "11",
+            "label": "按账号host范围拆分权限与连接配置"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "从需求写出一套可被反例检验的表设计",
+        "prompt": "先不看原表定义,为“旅行收纳袋 25.90 元,可归入户外和收纳分类”写出字段设计表与关系图,再在本机练习环境用新样品验证。已有表只核对和复用;不要为重做题目删除原表。设计中必须写清缺值、重复、删除和排序规则。",
+        "checkpoints": [
+          "设计材料写清每表一行的含义,商品稳定主键、名称可重名、整数价格分、下架默认值、UTC 时间与版本维护;逐项说明类型、非空、默认和检查约束的用途。",
+          "关系材料明确父子类型一致、组合唯一、分类名称唯一、双向查询访问路径,以及本课商品级联、被引用分类限制删除的选择。",
+          "用自己创建的实际编号证明合法价格和两条归类能保存;分别提交空白名称、负价格、重复归类与不存在父编号,记录预期拒绝及失败后原合法数据仍在。",
+          "读出两个分类和另一个未分类样品的 0,验证下架仍保留关联;只清理自己新增的样品,并以建表实际输出核对设计没有停留在纸面。"
+        ],
+        "misconception": "只交一张字段清单,不验证规则是否落到真实表;或为快速重做直接删掉贯穿课程的表。设计能力需要实际约束与反例证据,复习也要维护既有实验边界。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "从商品需求设计表结构、字段与数据约束"
+          },
+          {
+            "lessonId": "04",
+            "label": "设计商品分类关系表、主外键与删除策略"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "证明备份能恢复,再验证一次兼容加列",
+        "prompt": "为本机商品库准备一次独立恢复验收。先写出源库、独立目标库、暂停写入的范围与核对项目,再生成备份并恢复到专用验收库。确认数据后,对照课程验证恢复库可写和源库兼容加列;已有相同列或非空目标时先盘点,不重复执行。",
+        "checkpoints": [
+          "先核对目标数据库和账号,保护本机凭据文件,备份成功后检查文件校验和;不会把导入目标误指回源库,也不用跳过错误的方式把失败导入宣称成功。",
+          "在暂停本次练习写入的条件下比较商品数量、编号范围、上架数量与价格分总和,并核对两个方向的外键孤儿计数以及实际主键、CHECK、删除规则。",
+          "在恢复库试写新样品,取得实际自动编号后回滚;兼容加列明确目标和算法边界,验证旧查询与新查询均能工作,并说明 DDL 不能依赖普通回滚撤销。",
+          "交出实际恢复结果与用时,区分已完成的逻辑恢复和仍需一致坐标、连续 binlog、停止点与隔离演练的时间点恢复;证据缺失处写未验证。"
+        ],
+        "misconception": "把文件存在、校验和正确或单项行数相同直接等同于恢复成功。文件完整不证明数据关系和后续写入可用,也不能证明已经具备时间点恢复能力。",
+        "revisit": [
+          {
+            "lessonId": "11",
+            "label": "按账号host范围拆分权限与连接配置"
+          },
+          {
+            "lessonId": "12",
+            "label": "用mysqldump恢复并验收MySQL结构变更"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立从轻量商品需求推导字段、主外键、约束和删除规则。",
+        "evidence": "交出一份字段表与关系图,逐项回答规则服务于什么操作,并用真实结构输出和至少四个失败样例印证。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立完成参数化写入并正确结束成功和失败事务。",
+        "evidence": "复现同一真实编号的创建、修改和清理,以及第二步失败后整笔回滚;保留合法数据与连接恢复的检查结果。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立用双会话和执行计划验证并发与查询成本。",
+        "evidence": "不看答案记录一次旧值/新值对照和一次索引或分页对照,写明顺序、实际行数、最终版本及实验局限。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立区分连接、锁、权限与恢复问题,并核对修复后的状态。",
+        "evidence": "完成一个受控故障的恢复请求与一份独立恢复验收记录;清楚标明未执行的项目,不能仅凭没有报错自评通过。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "03",
+    "group": "接口与数据",
+    "title": "API 与数据库实战:我能守住一次商品操作的边界吗",
+    "summary": "把请求、Session、事务与真实表连成一条线,检查数据契约、分页、迁移、版本冲突、重复创建和通知重试是否都有验证依据。",
+    "sourceModule": "api-database",
+    "intro": {
+      "scenario": "商品已经从内存搬进 MySQL,但能返回 201 还不够:两个人可能同时改同一件商品,创建响应可能丢失,通知可能重复。只用本课的小商品目录,检验你能否把这些结果解释清楚并验证到数据库。",
+      "startingPoint": [
+        "已完成 API 与数据库实战模块,保留 shop-service 的配置、迁移和探针。先确认当前连接的库名:前半模块使用 shop_lab,迁移后实验使用 shop_lab_migration,测试只指向独立 shop_lab_test。",
+        "先独立作答,再展开参考要点;操作题只用随机标记的本机样品。并发和提交问题按原课真实连接实验执行,不把多个任务塞进同一个 Session。",
+        "自评依据自己能交出的结果;测试输出、手工故障观察与未验证假设分别标记,不将熟悉术语或看懂答案当作客观通过。"
+      ],
+      "outcome": "用独立解释、HTTP 与 SQL 双向结果、迁移检查和故障恢复证据,判断自己能否维护这条商品数据链路。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "为什么能共享 Engine,却不能共享 Session?",
+        "prompt": "商品 API 的 /live 正常、/ready 失败;另一个同学让两个并发查询共用 Session,并认为连接池只需按一个进程计算。请说明三个问题的检查顺序,并计算两个实例、每实例三个 worker、每进程最多五条连接的稳态与新旧发布重叠上限。",
+        "checkpoints": [
+          "/live 只证明进程能回应,/ready 还要实际借连接并查询数据库;先核对配置、数据库可达性与借用情况,不把存活结果当作数据链路已准备好。",
+          "每进程复用 Engine 池,每请求或独立任务建立自己的 Session;Session 含事务状态,并发分支不能共享,物理连接之后可被池重复借用。",
+          "例中稳态应用上限为 30,发布新旧完全重叠可到 60;还要加入任务消费者、迁移和诊断连接并留余量,不能只拿单池五条和数据库总限额比较。",
+          "分别观察池等待、建连接、行锁与 SELECT 的预算;请求结束后核对活动事务和后续请求,Sleep 连接可能只是空闲复用,pre_ping 或 recycle 不会恢复正在执行的失败事务。"
+        ],
+        "misconception": "把“复用数据库资源”理解成所有任务共用一个 Session,或将每进程容量当作全服务容量。前者混用事务状态,后者会在扩进程或发布重叠时低估连接量。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "异步 Session 的生命周期"
+          },
+          {
+            "lessonId": "06",
+            "label": "连接池预算、超时与多 Worker"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "add、flush、commit 与返回 201 是同一件事吗?",
+        "prompt": "创建一件 1990 分陶瓷杯时,程序已拿到 id;有人据此立刻返回成功,再在后台提交。另一次 PATCH 没写价格与明确提交 null 被当作相同情况。请说明这两处为什么不符合本课的数据与事务约定,并指出时间输出要核对什么。",
+        "checkpoints": [
+          "SQL 真实表、ORM 映射与 Pydantic 输入输出要对齐字段、缺值和价格边界;id 与创建时间由服务/数据库产生,不能把任意输入直接当作公开输出。",
+          "add 登记对象,flush 才发送 INSERT 并可取得生成 id,refresh 用来显式读取数据库生成值;拿到 id 不证明事务已提交。",
+          "在明确事务内形成输出数据,成功离开事务边界后才返回成功;辅助函数不偷偷提交,失败时让异常触发整笔回滚,不能在响应之后隐藏提交结果。",
+          "PATCH 省略价格表示保留原值,显式 null 按本课契约拒绝;DATETIME(6) 存储值依既定 UTC 解释,响应带明确 UTC 时区,updated_at 要由写路径维护。"
+        ],
+        "misconception": "把 flush 当 commit,或把缺字段与空值统一填成默认值。前者会在尚未持久化时宣称成功,后者可能把本来要求保留的价格清空或重置。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "ORM CRUD 与输入输出模型"
+          },
+          {
+            "lessonId": "04",
+            "label": "事务边界、约束失败与提交结果未知"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "商品分页了,评价读取就一定有上限吗?",
+        "prompt": "列表只取 20 件商品,但每次访问商品的评价属性都追加查询;改成批量预加载后,有一件商品仍带出很多评价。请说明两次性能问题的不同原因,并设计本课范围内的商品和评价分页边界。",
+        "checkpoints": [
+          "逐商品读取未显式加载的评价会形成 1+N 次往返,异步环境的隐式读取还可能报错;先记录实际 SQL 次数,明确加载后再访问关系。",
+          "selectinload 可以将受控商品集合的关系读取合并,但限制商品数量不等于限制评价总数;真实接口按商品单独分页评价,区分无商品的 404 与无评价的空列表。",
+          "商品列表使用创建时间与 id 的稳定复合倒序,下一页带上上一页末尾游标;保留相同条件并用实际计划确认访问范围,游标非法时在构建查询前拒绝。",
+          "评价列表按评价 id 有界继续取,最多额外读取一条判断下一页;游标编码不是授权或加密,各分页请求也不是一个跨请求一致快照。"
+        ],
+        "misconception": "把减少查询次数等同于限制返回规模,或认为用了 Base64 游标就安全且全程快照一致。批量加载、分页边界、权限和事务范围是不同约定。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "商品评价关系与 N+1 查询"
+          },
+          {
+            "lessonId": "08",
+            "label": "Keyset 分页与数据库错误映射"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "迁移版本号没前进,表一定没变吗?",
+        "prompt": "自动生成的迁移执行中途失败,版本号没有推进;同学准备直接再跑一次,并把同一个迁移放进普通回滚测试中。请说明先查什么,为什么本课从独立空库建立基线,以及新增 version 时为何先扩展、回填再收紧。",
+        "checkpoints": [
+          "模型是目标结构,revision 是演进步骤,autogenerate 只给待审查差异;先核对实际表结构和已执行动作,版本号未变不保证结构完全未变。",
+          "MySQL 结构操作有隐式提交边界,单条原子 DDL 不代表整个多语句迁移能被 ROLLBACK 撤销;普通 DML 保存点测试也不应承担迁移回滚验证。",
+          "原 shop_lab 可能已被数据库模块加入 version、索引和约束,所以本课使用独立 shop_lab_migration 建立基线,保留原库,不对已有状态盲目 stamp 或重复加列。",
+          "先做与旧代码兼容的扩展,再按主键有界回填并核对剩余量,最后收紧约束与模型;每阶段都审核迁移、查看实际结构并验证旧新代码可用,不宣称所有 DDL 无锁。"
+        ],
+        "misconception": "只看迁移版本表判断是否可以无脑重跑,或认为外层事务能兜住所有 DDL。结构可能部分改变,重复执行和错误测试隔离都会掩盖真实风险。",
+        "revisit": [
+          {
+            "lessonId": "07",
+            "label": "Alembic迁移、DDL边界与兼容发布"
+          },
+          {
+            "lessonId": "12",
+            "label": "真实MySQL隔离测试与故障验收"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "旧版本冲突与响应丢失重试,为什么需要两种机制?",
+        "prompt": "两位同学都读到陶瓷杯 version=1;另一位创建新商品时没收到响应,决定重新发请求。请分别说明应该携带什么信息、数据库如何裁决,以及哪些返回和持久结果能证明机制生效。",
+        "checkpoints": [
+          "并发编辑把读取到的旧版本作为条件,与字段更新和版本递增放在同一条原子 UPDATE;第一次成功后旧版本请求得到冲突,不通过先 SELECT 再无条件写入实现。",
+          "所有修改和删除路径都遵守已启用的版本协议,旧入口或不递增版本的脚本会破坏保护;本课最终删除同样要求当前版本。",
+          "一次逻辑创建重试保持同一个 Idempotency-Key,由可信操作作用域和唯一键约束串行化争用,保存状态与响应供重放;网络错误本身无法判断是否已提交。",
+          "同键同输入应返回首次结果与相同 id,同键不同输入应冲突;新键意味着新的逻辑创建。幂等表、商品与结果保存在同一事务,不能只在内存记一份键。"
+        ],
+        "misconception": "把 version 当创建去重键,或丢响应后每次换新幂等键。版本解决旧编辑覆盖,幂等键解决同次创建重复;混用会漏掉各自需要保护的窗口。",
+        "revisit": [
+          {
+            "lessonId": "09",
+            "label": "并发更新:版本条件与行锁"
+          },
+          {
+            "lessonId": "10",
+            "label": "幂等键与唯一约束"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "通知已经被接收,为什么还可能再发一次?",
+        "prompt": "商品与待发事件同事务写入后,发布器发送成功,但本地确认没记上。请推演下一轮会发生什么,说明领取租约、确认 token、接收方 event_id 分别保护哪一段,以及最终应查哪些状态。",
+        "checkpoints": [
+          "商品与 outbox 同一事务保存,避免商品提交后通知记录漏写;发布器在短事务领取并提交后再发 HTTP,不在持锁事务中等待外部服务。",
+          "发送成功而本地确认丢失时,接收方可能已完成,待发事件仍可在租约到期后重领并重复发送,因此本课是至少一次交付而非自动恰好一次。",
+          "领取 token 防止旧执行者改写不属于自己的本地确认状态,但不能阻止旧执行者继续发网络请求;接收方使用 event_id 与自身事务去重。",
+          "核对商品与事件只产生一次、接收表同 event_id 仍一行、发送确认最终落下,并观察尝试次数与积压。幂等创建原键重放也不应再新增 outbox 事件。"
+        ],
+        "misconception": "以为有租约和 token 就不会重复发送,或把 HTTP 返回成功当作本地 delivered_at 已写入。网络发送、接收提交和本地确认是不同动作,需要分别核对。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "幂等键与唯一约束"
+          },
+          {
+            "lessonId": "11",
+            "label": "事务 Outbox 与跨服务一致性"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "给一件商品做双端验收,再复现旧页面冲突",
+        "prompt": "在本机已完成迁移的练习环境中,使用新标记创建一件 2590 分旅行收纳袋。独立从 HTTP 与 SQL 两边核对,模拟两个客户端读到同一版本,再完成一次成功修改、一次旧版本冲突和带版本删除。先写出每一步的预期,操作后再对照要点。",
+        "checkpoints": [
+          "先核对当前配置、库名、迁移状态和 /ready,用响应真实 id 定位数据;HTTP 与 SQL 都能确认名称、整数价格、上架状态以及按 UTC 约定的时间。",
+          "两个客户端分别保存同一版本快照;A 修改后读取新值和新版本,B 携旧版本被拒绝,SQL 中仍为 A 的结果且版本只增加一次。",
+          "加入一次非法价格或显式 null 的 PATCH,记录输入拒绝及原记录未被改写;说明省略字段与 null 的不同,不借冲突操作覆盖正确数据。",
+          "用当前版本删除得到 204 空体,再核对实际记录不存在;保留整个请求顺序、状态与数据库结果,解释本模块 PATCH 和版本删除不能照搬基础 API 的无版本 PUT 流程。"
+        ],
+        "misconception": "只在接口显示冲突就结束,或用错误版本清理失败后直接手工删数据掩盖结果。需要核对未覆盖的持久状态,并遵守最终写入协议完成后续操作。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "ORM CRUD 与输入输出模型"
+          },
+          {
+            "lessonId": "09",
+            "label": "并发更新:版本条件与行锁"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "重试与通知去重能在真实提交下成立吗?",
+        "prompt": "只在独立测试库安排一次同键并发创建,再演练通知接收成功但本地确认丢失。先说明为什么不能把所有并发任务绑定到同一个回滚 fixture,再写出数据核对和恢复计划。独立完成后记录实际结果;未执行的故障分支不标通过。",
+        "checkpoints": [
+          "先核对测试 URL、库名和权限,运行已有迁移;并发任务各用自己的 Session 与连接,需要真实提交的部分使用相应独立 fixture,不能用共享外层事务证明跨连接可见性。",
+          "同一随机幂等键与同输入并发创建,响应状态和内容相同;按随机标记核对只产生一件商品、一份结果和一条 outbox,不只比较两次 HTTP 响应。",
+          "发送到本机去重接收端后按原课跳过一次本地确认,等待租约条件满足再恢复发布;接收表仍只有该 event_id 一行,待发记录最终确认,解释为什么重复发送是允许出现的。",
+          "记录故障前、中、后的 HTTP、数据库、连接释放与后续恢复请求;清理仅针对本次随机键相关数据,并区分自动断言、手工记录与未验证项。"
+        ],
+        "misconception": "在同一连接内看见相同结果,就声称并发唯一性和真实提交已经验证。共享事务、保存点与真实独立连接的可见性不同,恰好避开竞争不能证明故障处理正确。",
+        "revisit": [
+          {
+            "lessonId": "11",
+            "label": "事务 Outbox 与跨服务一致性"
+          },
+          {
+            "lessonId": "12",
+            "label": "真实MySQL隔离测试与故障验收"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立解释请求、Session、flush、commit 与公开响应之间的边界。",
+        "evidence": "不看参考要点画出一次创建及第二次写入失败的流程,标出已执行、未提交、回滚和可返回成功的位置,并用一组实测记录对应。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立对齐表、ORM 和输入输出规则,完成带版本的商品操作。",
+        "evidence": "交出真实 id 的 HTTP/SQL 双端结果、UTC 与价格检查,以及一次旧版本冲突和按当前版本删除的完整证据。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立验证查询规模、连接预算和迁移后的真实结构。",
+        "evidence": "交出 SQL 次数或分页边界记录、包含多进程及发布重叠的连接预算、迁移状态与实际结构对照,不能只交配置值或生成的迁移文件。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立判断提交未知、重复创建与重复通知,并选择合适的验收隔离。",
+        "evidence": "在独立测试库完成一组真实提交故障实验,核对幂等结果、事件去重、连接释放与恢复请求;明确说明保存点测试不能证明的部分。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "04",
+    "group": "并发、缓存与容器",
+    "title": "异步任务:我真的掌握了吗",
+    "sourceModule": "tasks",
+    "summary": "围绕商品清单导出,检查自己能否区分受理、执行与完成,并用数据库、日志和文件证据判断重复、等待和恢复。",
+    "intro": {
+      "scenario": "运营提交一份商品清单,页面已经返回任务编号,但稍后仍查不到文件。先不打开参考要点,沿着你认为必要的证据说明任务可能停在哪一段,再完成下面的判断与变式。",
+      "startingPoint": [
+        "已完成异步任务模块,能找到最终 tasks_lab 的状态存储、worker、publisher、recovery 和终态验证文件;第 01 课内存实验与第 11 课后的持久实现要分开判断。",
+        "每题先写自己的结论、理由与验证办法,再展开要点。需要操作时只用原课本机专用实例和新的 job_id,不清空队列、任务表或其他任务的输出。",
+        "将“尚未验证”“借助提示完成”和“能独立完成”分开记录;熟悉答案不能替代自己得到的状态、日志与文件证据。"
+      ],
+      "outcome": "用导出链路说明和有证据的变式记录,找出执行位置、交付窗口、容量与恢复方面仍需回看的课时。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "拿到 202,究竟确认了什么?",
+        "prompt": "同学在第 01 课拿到 202 就显示“文件已完成”;另一位给第 03 课后台函数加上 await,认为这样就能跨服务重启恢复。分别判断两种说法,并说明提交响应、后续查询和重启实验应该怎样核对。",
+        "checkpoints": [
+          "202 表示受理,响应提供 job_id、当前状态与查询地址;文件完成要通过后续状态和结果确认。第 01 课尚未接执行器,任务保持 queued 是正常现象。",
+          "第 03 课后台函数能推进状态,却仍属于应用进程;添加 await 改变等待方式,不会把任务持久保存。",
+          "正常关闭可能给任务清理时间,强制退出不保证执行 finally。重启后不能假定内存记录与未完成工作会恢复。",
+          "保存真实编号,先查状态、完成后查结果,再在本机重启对应实验进程查询原编号;分别记录终态保护和记录消失,不能把响应发出当作完成证据。"
+        ],
+        "misconception": "把“HTTP 已返回”“后台任务已登记”和“业务已完成”当成同一件事,会提前承诺结果,也会遗漏进程退出后的工作丢失。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "提交导出任务,返回 202 与可查询状态"
+          },
+          {
+            "lessonId": "03",
+            "label": "区分 BackgroundTasks 与 create_task 的生命周期"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "限了并发,为什么仍会堆积或卡住?",
+        "prompt": "商品读取最多允许两个并发。同学一次创建大量 Task,再给读取加 Semaphore;另一份线程版在编号 7 失败时跳过 task_done。请分别预测排队与退出行为,给出只用 20 个编号即可执行的验证方案。",
+        "checkpoints": [
+          "Semaphore 只限制进入读取区的并发,不限制已创建的 Task 和等待项;有限队列与固定消费者才能同时约束排队量及执行者数量。",
+          "每次取出任务都应结清,包括失败项与结束哨兵;遗漏 task_done 会让队列 join 一直等待未归零的计数。",
+          "共享成功/失败计数放在短锁区,耗时工作放在锁外;队列计数清零与所有线程退出还需分别检查,不能靠队列暂时为空判断完成。",
+          "记录真实读取峰值不超过 2。线程版消费完全部 20 项时核对成功数加失败数为 20,并确认线程退出;协程 TaskGroup 的失败或超时分支会取消其他任务,应核对消费者取消清理、没有误报完整成功,并把未执行项单独记录,不当作已失败的任务凑总数。"
+        ],
+        "misconception": "“加并发限制就不会排队”混淆执行与提交;“失败项不用结清”会让协调者永远等不到正确的完成计数。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "用 TaskGroup、队列与超时约束 asyncio"
+          },
+          {
+            "lessonId": "04",
+            "label": "用 Lock 与 Queue 管理线程共享状态"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "等待超时以后,还剩谁在运行?",
+        "prompt": "导出的一段调用同步读取库,另一段只做纯 Python 文字计算。为它们选择执行方式,并解释为什么调用者超时了,仍不能宣布所有后台工作已停止。你会记录什么来判断进程池是否值得使用?",
+        "checkpoints": [
+          "同步阻塞 I/O 可交给受限线程池,并限制提交数量;纯 Python 计算可用小规模进程池比较,不能因为业务叫“异步任务”就把阻塞工作直接放进事件循环。",
+          "Future 等待超时只结束调用者的等待;已运行线程不能靠 cancel 安全强杀。同步库的超时及分段检查 Event 才构成协作停止边界。",
+          "进程函数应在可导入模块顶层,传可序列化数据,并保留显式 spawn 与入口保护;进程结果等待超时也不证明 CPU 函数已经结束。",
+          "保持输入与计时范围一致,先核对串行与进程结果相同,再记录耗时;小任务可能因启动和传输更慢。退出还需工作函数结束和执行器收尾证据。"
+        ],
+        "misconception": "“TimeoutError 就是函数被终止”会遗漏仍运行的工作;“进程越多一定越快”忽略任务规模、启动成本和资源限制。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "在线程池中调用阻塞 I/O"
+          },
+          {
+            "lessonId": "06",
+            "label": "用进程池处理 CPU 密集导出"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "同一导出出现两条消息,是失败吗?",
+        "prompt": "同一 job_id 和固定商品快照被两条不同消息送到 worker。第一次已写好文件,却在确认消息前退出。解释再次执行是否符合设计,以及怎样判断重复没有形成冲突的业务结果。",
+        "checkpoints": [
+          "提交、转交、执行与确认是不同阶段;完成后确认仍存在“副作用已发生但确认未送达”的窗口,因此重投不自动说明系统失效。",
+          "第 09 课的相同业务编号与不可变快照应生成相同目标路径和内容;比较两次路径、摘要和完整 JSON,而不是只看消息缓存的 SUCCESS。",
+          "临时文件后原子替换避免半文件;同一 job_id 如果携带不同内容,它不能决定哪个版本正确,也不能证明跨机器或断电的一致性。",
+          "消息 ID 区分交付,job_id 区分业务导出;相同 task_id 本身不保证去重。说明 broker 配置和恢复前提,不把 late ack 或原子替换称为恰好一次。"
+        ],
+        "misconception": "把重复交付一概当成业务失败,会绕开恢复机制;把相同消息编号当作去重锁,也无法约束实际函数执行或输出版本。",
+        "revisit": [
+          {
+            "lessonId": "08",
+            "label": "启动 Celery broker、worker 与结果缓存"
+          },
+          {
+            "lessonId": "09",
+            "label": "理解 ack、重投与幂等输出"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "次数还没用完,为什么不能再试?",
+        "prompt": "一个任务还有重试额度,但总截止时间已过;另一个收到重复消息,next_attempt_at 却仍在未来。分别判断能否读取或领取,并区分暂时错误、永久错误与实际执行次数。",
+        "checkpoints": [
+          "尝试必须同时满足次数与总时间条件;过了 deadline 应在真实读取前结束,不能因还有次数就重新获得一整份时间预算。",
+          "只对已知且有机会恢复的暂时错误设置有限重试,永久输入错误直接失败;退避与抖动分散压力,不能替代单次 I/O 超时。",
+          "持久领取需检查状态、取消、截止时间、次数和 next_attempt_at;未来时间未到,重复消息也不能领取,更不能提前增加 attempts。",
+          "消息重新交付次数不等于实际业务执行次数。用过期预算、永久错误及未到领取时间的样本分别记录真实调用和领取是否发生,而非只数日志行。"
+        ],
+        "misconception": "“剩余次数必须用完”让过期工作继续耗资源;只延迟某条消息而不检查持久时间,会被另一条重复消息提前触发。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "为重试设置退避、抖动与总预算"
+          },
+          {
+            "lessonId": "11",
+            "label": "用 MySQL 保存任务状态与租约"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "任务已落库,消息却没到怎么办?",
+        "prompt": "导出记录已提交时 broker 不可用,后来又发生发送成功但 sent_at 没写回。请画出不用代码的状态顺序,说明 outbox 如何补遗漏,以及为何 worker 仍需领取与完成条件。",
+        "checkpoints": [
+          "任务和待发事件在同一 MySQL 事务内保存,任一步失败一起回滚;提交端在持久保存后才表示受理,不把发送网络消息放进这个事务。",
+          "publisher 短事务领取后先提交,再发消息,最后按当前 token 与有效租约确认。broker 不可用时保留未发送事件,等待有预算的恢复。",
+          "发送成功却没确认会重复发送;outbox 保存发送意图,并不保证恰好一次。worker 仍按状态、token、租约、deadline 和取消条件决定是否执行及写回。",
+          "派发确认也不等于 worker 已领取。长期 queued 的恢复需要检查时间和已有 pending 事件,避免重复堆通知;重新派发不能直接算一次实际执行。"
+        ],
+        "misconception": "“有 outbox 就能删掉幂等和恢复检查”混淆保存发送意图、重复交付与业务结果写回三种不同问题。",
+        "revisit": [
+          {
+            "lessonId": "12",
+            "label": "用事务 outbox 连接状态提交与消息发布"
+          },
+          {
+            "lessonId": "15",
+            "label": "实现优雅退出、取消与失败隔离恢复"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "短清单排队与周期重复同时出现",
+        "prompt": "有一份两件商品的短清单和一份较长清单,又开启了每分钟导出。先独立设计两组小实验:判断队列拆分与容量是否有效;区分同分钟重复与跨分钟重投。只用本机专用任务,写出能够支持和推翻判断的证据。",
+        "checkpoints": [
+          "将 queue_name 随 outbox 保存,确认 fast/bulk 真有对应且受限的消费者;查看实际路由和等待,不能只改队列名字就宣称隔离了执行容量。",
+          "先统计已有 queued/running,所有提交路径使用相同容量门闩和额度;满额拒绝新增并给可重试的 503,不能返回不存在任务的 202。",
+          "生成新的实验时间槽,同槽重复只创建一个 job 及事件;另一个明确执行分钟可产生新槽,当前模型因此不保证原始信号的恰好一次。",
+          "保存受理结果、队列、任务编号、槽记录和等待年龄,恢复消费者后核对结果;调度器停机的分钟不会自动补跑,不把未实现能力写成结论。"
+        ],
+        "misconception": "“拆队列等于自动隔离资源”和“同槽唯一等于任何重投都只执行一次”分别忽略消费者容量和时间槽采用的时间语义。",
+        "revisit": [
+          {
+            "lessonId": "13",
+            "label": "限制受理量并拆分长短任务队列"
+          },
+          {
+            "lessonId": "14",
+            "label": "用持久时间槽合并周期执行,并验证跨分钟重投边界"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "一个旧执行者晚回来了",
+        "prompt": "先写计划,再在原课专用环境验证:A 持有租约后失效,扫描恢复并等待退避后由 B 接管,A 又晚到写回。追加取消或次数耗尽的一条分支。要保留什么,才有依据记为“能独立完成”?",
+        "checkpoints": [
+          "只操作明确的新 job_id,记录 A 的 token、attempts 和期限;过期后由扫描转换,确认持久退避,到期才让 B 领取,重复消息不能提前增加次数。",
+          "旧 token 完成应失败,B 生成独立真实文件后才能写入有效 result_ref;数据库引用的文件必须等于固定快照,不能仅看 Celery 状态。",
+          "取消分支不能发布成功;次数或期限耗尽需保留失败隔离记录,不清零旧任务伪造恢复。修复重放使用带 replay_of 的新任务。",
+          "保存阶段日志、业务与消息编号、状态变化、实际恢复耗时和文件验证;未做分支标未验证,超预算要说明等待来自租约、扫描、退避还是队列。"
+        ],
+        "misconception": "“A 曾拿到锁就仍有资格完成”忽略当前权属;“看懂恢复流程”也不能替代旧令牌被拒绝和结果文件一致的实际观察。",
+        "revisit": [
+          {
+            "lessonId": "15",
+            "label": "实现优雅退出、取消与失败隔离恢复"
+          },
+          {
+            "lessonId": "16",
+            "label": "完成故障注入与可观测性验收"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立解释受理、消息交付、工作执行与业务完成为何不是同一件事。",
+        "evidence": "不看答案画出一次导出的状态顺序,标出两个失败窗口,并说明每阶段该查哪份记录。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立选择并约束协程、线程或进程,并完成一项小导出。",
+        "evidence": "自行准备输入和执行、排队上限,保存正常结果、一条失败分支和实际退出证据。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立证明重复交付没有改变本次有效结果。",
+        "evidence": "用新 job_id 得到重复消息记录、唯一有效 result_ref 和与固定快照一致的文件,并解释消息数与 attempts 的区别。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立判断任务在等待、失败、失去租约或已终止,并选择对应恢复动作。",
+        "evidence": "为一次故障留下状态、当前 token、期限、恢复耗时和最终结果记录,同时明确尚未实测的分支。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "05",
+    "group": "并发、缓存与容器",
+    "title": "Redis 缓存:我真的掌握了吗",
+    "sourceModule": "redis",
+    "summary": "从同一商品的两次读取出发,检查自己能否解释命中、旧值、期限与故障,并守住 MySQL 的读取容量。",
+    "intro": {
+      "scenario": "商品在 MySQL 中已经改名,页面偶尔仍显示旧名。同学建议把 TTL 设得很长,并在 Redis 故障时让所有请求直查数据库。先独立判断这些做法改变了什么,再完成下面的自测。",
+      "startingPoint": [
+        "已完成 Redis 模块,能找到原课读取、失效、单飞、降级与探针文件,知道本机商品样本及各专用 Redis 实例的用途。",
+        "每题先写时序、判断和取证办法,再展开要点。操作时复用受限配置与真实 PRODUCT_ID,只改本次键或原课明确的专用容器。",
+        "正常、故障和未执行项目分别记录;自评帮助定位薄弱点,不因看完答案或勾选条目自动证明掌握。"
+      ],
+      "outcome": "用来源、版本、期限、回源次数和恢复证据,判断自己能否独立维护一条可解释且容量受控的商品缓存读路径。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "第二次更快,是否证明缓存正确?",
+        "prompt": "连续读同一商品,第一次来源 database,第二次 cache。有人只覆盖缓存值,TTL 随后为 -1;另一人把缓存 schema 当成商品 version 一起递增。还需验证什么?这两个修改分别有什么问题?",
+        "checkpoints": [
+          "命中只说明找到键,还要核对真实商品编号、两次内容、整数分价格和 UTC 时间等约定字段;坏 JSON 或不支持的结构应回源修复。",
+          "schema 表示缓存编码结构,商品 version 表示业务数据版本;两者不能替代,商品改名不自动意味着编码格式改变。",
+          "TTL 为 -1 表示存在但无期限,-2 才是键不存在。普通 SET 覆盖可能清掉原期限,不能假定第一次设置的期限会一直保留。",
+          "只对真实样本的明确键做实验,记录两次来源、内容对比与剩余期限;再注入坏结构,确认来源回到 database 并写入合法副本。"
+        ],
+        "misconception": "“读得快就是读得对”忽略内容与期限;把 schema 当业务版本,也无法说明这份商品是否比数据库旧。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "连接 Redis、选择类型并设置 TTL"
+          },
+          {
+            "lessonId": "02",
+            "label": "实现 Cache-Aside 与带版本的 JSON 缓存"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "提交后删缓存,旧值为什么还能回来?",
+        "prompt": "A 已从 MySQL 拿到旧商品却尚未回填。B 提交新名称并删缓存,随后 A 才填入旧值。请按顺序写出缓存和数据库版本,再说明提交后失效解决了什么、没有解决什么。",
+        "checkpoints": [
+          "A 回填后缓存可命中旧版本,而 MySQL 已为更高版本;课程通过手工安排顺序就能稳定复现,不必依赖碰巧的线程调度。",
+          "先提交再失效可避免新值尚未提交时读者回源旧值,但不能阻止已经拿到旧快照的请求晚到回填。",
+          "修改先用版本条件和受影响行数核对,再返回已提交的真实结果;提交应答未知不能盲目更换条件重试。",
+          "失效失败需保留可恢复工作,可按课程范围以持久事件补偿,并明确详情及派生键范围。需要强一致读时显式绕过缓存,不能由一次 DEL 或短 TTL 承诺强一致。"
+        ],
+        "misconception": "“提交后删过一次,之后绝不会读旧值”漏掉在途旧读取;吞掉失效失败还会让更新副本的工作无法追踪或恢复。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "提交后失效缓存,并复现旧值回填竞态"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "“没有商品”和“数据库没连上”能一样缓存吗?",
+        "prompt": "连续查询合法但不存在的商品编号时,你想减少回源。一次请求却遇到 MySQL 连接错误,稍后同编号商品又可能被创建。分别设计返回含义、短期缓存与写入失效,并说明非法编号应在哪里被拒绝。",
+        "checkpoints": [
+          "只有数据库成功查询且确定无行,才能缓存明确 found=False 的缺失结论;连接失败不代表不存在,不能存成同一种空结果。",
+          "正负结果需可区分的编码,负缓存用课程中更短的期限,并与普通详情缓存使用清楚的命名空间,避免错误解码。",
+          "创建、更新和删除提交后都要考虑详情及 lookup 键;漏掉新建编号的负缓存,会让后来真实商品继续被旧缺失结论挡住。",
+          "编号先做严格类型及范围检查,拒绝 0、负数、字符串、True 和超出 BIGINT 正范围的值;这些输入不应先访问 Redis 或 MySQL。"
+        ],
+        "misconception": "“没拿到商品就缓存空值”混淆无记录与依赖故障,既隐藏故障,又可能让真实商品被误判不存在。",
+        "revisit": [
+          {
+            "lessonId": "04",
+            "label": "阻止缓存穿透:输入校验与短期负缓存"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "锁过期了,原填充者还能删锁吗?",
+        "prompt": "A 取得热门商品填充权后变慢,锁过期,B 成为新持有者。A 恢复后准备写缓存并删锁。请说明应该如何判断这两个动作,再设计不靠随机调度的验证。",
+        "checkpoints": [
+          "单飞合并同一时段的重复填充;取得权利后还要再读缓存,避免别人已填好。没有取得权利的读者只作有限等待。",
+          "过期后可能出现新持有者,A 必须比较自己的 token 与当前值;旧 token 不得删除 B 的锁,也不得在失去权属时发布缓存。",
+          "比较与删除或发布应在短 Lua 脚本中连续执行,防止检查后到修改前所有权改变;普通先 GET 再 DEL 不能保证这个组合。",
+          "用专用键先写 old-owner 再写 new-owner,验证旧 token 释放返回 0 且新锁仍在;另以固定小并发和计数 loader 核对真实回源次数,超时保留为失败证据。"
+        ],
+        "misconception": "“我曾拿到锁,所以最后必须由我释放”把过去权属当成当前权属;直接 DEL 可能删掉后来者的锁,造成更多并发回源。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "抑制热点击穿:带令牌的单飞回填"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "Redis 停了,全部直查 MySQL 就能保住接口吗?",
+        "prompt": "先遇到一批商品缓存同时到期,随后模拟整个 Redis 停止。分别说明 TTL 抖动、少量预热和有界回源能解决哪部分,并写出数据库回源名额耗尽时应该出现什么结果。",
+        "checkpoints": [
+          "TTL 抖动错开一批键的正常过期,减少集中回源;它不能阻止整个 Redis 不可用,也不能证明返回内容一定足够新。",
+          "预热只处理有限热点或课程中的最近少量商品,限制数量和节奏,释放取编号的数据库事务后再逐件填充,不制造无界全库压力。",
+          "Redis GET/SET 失败可绕过,但回源仍需本进程并发名额与短等待;多进程总预算要相加,不能只看一个 Semaphore 数字。",
+          "拿不到名额应出现 SourceBusy 等受控容量失败,由接口明确响应;MySQL 本身错误仍传播。恢复 Redis 后验证来源逐步由 database 转为 cache。"
+        ],
+        "misconception": "“降级就是取消限制、全查真源”可能把缓存故障扩大为数据库故障;高命中率也不能证明故障期间有足够容量。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "应对缓存雪崩:TTL 抖动、受控预热与降级"
+          },
+          {
+            "lessonId": "09",
+            "label": "配置连接池、超时与熔断并记录缓存指标"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "少发几次网络请求,等于中间不能被修改吗?",
+        "prompt": "商品查询限流需要增加计数,并在首次计数时设期限。同学建议不带事务的 Pipeline,还说网络中断后无限重试即可。比较 Pipeline、WATCH 与本课 Lua 的职责,并给出四次请求的预期观察。",
+        "checkpoints": [
+          "Pipeline 减少往返且按命令顺序返回,但 transaction=False 不让整批变成原子操作;网络错误不能证明所有命令都没执行。",
+          "WATCH 检查键是否在提交前被其他操作改动;冲突后重新读取再计算,并限制尝试次数,不能拿旧计算结果继续提交。",
+          "本课短 Lua 先检查参数,再组合 INCR 与首次 PEXPIRE,避免计数成功却漏设期限;脚本不做长循环或外部请求。",
+          "固定窗口中前三次允许、第四次拒绝且期限仍为正,到期后允许;这不是精确滑动窗口。超时重试可能重复计数,需承认保守限流边界,不能无限重试来猜结果。"
+        ],
+        "misconception": "“合并发送就是原子”和“客户端异常就是服务端没执行”混淆网络效率与执行语义,可能留下永久计数器或重复计数。",
+        "revisit": [
+          {
+            "lessonId": "07",
+            "label": "使用 Pipeline、WATCH 与 Lua 保证正确的原子操作"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "容量和故障延迟一起变差",
+        "prompt": "先独立写观测计划,再用原课专用实例验证:容量实例发生淘汰;商品缓存调用连续失败后被短暂绕过。报告必须区分淘汰、过期、正常未命中、连接错误和 bypass,并解释为何不能把 broker 搬进缓存实例的另一个 DB 来省资源。",
+        "checkpoints": [
+          "只在容量实例有限写入,记录前后 evicted_keys 增量、used_memory、RSS 与 maxmemory,再读取明确样本键计算本批命中率;不把累计值直接当作本次结果。",
+          "键可在 TTL 未到时因容量压力被淘汰;自然到期与淘汰分别记录。maxmemory 不等于 Redis 进程或容器全部内存占用。",
+          "先以固定故障函数验证三次真实错误后第四次 bypass,冷却探测后恢复;记录实际调用次数、状态和延迟。正常返回 None 是 miss,认证错误也不能用无限探测掩盖。",
+          "broker 与可重建缓存需要独立容量和可靠性安排,DB 编号不隔离内存、CPU 或故障;记录写入拒绝如何暴露,尚未执行的项目明确标出。"
+        ],
+        "misconception": "把所有 None 都计作未命中,会隐藏绕过与错误;另选一个 DB 也保护不了消息免受同实例的容量或故障影响。",
+        "revisit": [
+          {
+            "lessonId": "08",
+            "label": "控制缓存容量与淘汰,隔离任务 Broker"
+          },
+          {
+            "lessonId": "09",
+            "label": "配置连接池、超时与熔断并记录缓存指标"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "重启后读得到,就证明不会丢了吗?",
+        "prompt": "先写下三组实验的判断,再只用原课专用容器验证:无持久化缓存重启、独立 AOF 实例正常重启、主节点写入后副本读取。说明每组能证明什么,以及仍不能作出什么承诺。",
+        "checkpoints": [
+          "无持久化缓存的键可能消失,MySQL 商品应仍完整,首次回到 database、随后 cache;证明的是副本可重建,不是 Redis 保存了真源。",
+          "AOF 样本仅首次写入,重启后只读值和剩余 TTL;再次写会掩盖恢复失败。正常重启成功不等于已经验证断电或所有磁盘故障。",
+          "主节点写入后在同一连接按课程方式 WAIT,观察副本确认再读取;确认复制不等于磁盘持久化或强一致承诺。",
+          "停止专用主节点,核对副本链路 down 且不会自动升主,再恢复并检查。保留值、期限、角色、确认数及时间;不能用缓存实验代替任务 broker 的持久状态与交付恢复验收。"
+        ],
+        "misconception": "“正常重启保住样本,所以任何故障都不丢且副本自动接管”混淆了持久化、复制与故障切换。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "演练重启、复制与持久化恢复"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立解释商品缓存从缺失、回源、命中到失效的过程。",
+        "evidence": "不看答案画出正常读取与旧值晚回填两条时序,并说明 schema、商品 version 和 TTL 各表达什么事实。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立建立有格式验证、期限和明确缺失含义的读取路径。",
+        "evidence": "在新终端恢复配置,用真实样本展示 database/cache 对照、坏格式修复与负缓存读取,留下实际输出。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立验证减少回源的机制,而不只是觉得请求变快。",
+        "evidence": "交出有限并发下真实 loader 次数、旧 token 释放失败、回源上限及超出等待预算的可见结果。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立区分缓存容量、连接故障、主动绕过和恢复边界。",
+        "evidence": "记录命中率、淘汰增量、状态与延迟,并用重启或副本实验说明哪些数据来源及恢复能力已被验证。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "06",
+    "group": "并发、缓存与容器",
+    "title": "Docker 容器化:我真的掌握了吗",
+    "sourceModule": "docker",
+    "summary": "检查自己是否能从进程、网络、存储和发布证据出发,独立把商品服务与导出任务放入容器,并判断什么才算可恢复。",
+    "intro": {
+      "scenario": "商品 API 镜像已构建成功,容器显示 running,但商品读取失败,重建后文件还可能消失。先不看要点,用自己的顺序区分镜像内容、进程、连接与持久数据,再完成发布变式。",
+      "startingPoint": [
+        "已完成 Docker 模块,知道本机 Compose 项目、镜像、秘密文件与命名卷的位置;操作前先核对 Docker context 为专用练习环境。",
+        "先独立回答,再对照要点。实际演练只用原课有界样本和独立恢复容器,不删除业务卷,不把生产 context 当成实验环境。",
+        "记录真实镜像标识、状态、日志与恢复结果;未推送、未注入或未恢复的项目写未验证,不用“容器能运行”代替掌握证据。"
+      ],
+      "outcome": "形成能解释启动失败、验证数据保留并说明兼容回退边界的本机交付记录,据证据确定需要复习的部分。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "同一镜像,重启与重建有什么不同?",
+        "prompt": "主进程执行完后容器停止,同学想在外面加无限等待来“常驻服务”。另一位把导出文件写进容器,认为以后重新 run 同一镜像仍能找到。分别判断,并设计两个最小观察。",
+        "checkpoints": [
+          "容器运行的是主进程,主进程退出时停止是正常生命周期;应用已不服务时加无限等待不会让 API 重新提供服务。",
+          "stop 后 start 复用原容器和可写层;删除后重新 run 创建另一容器,同一镜像不包含前一个容器运行时写入的临时文件。",
+          "需要保留的数据放入明确持久存储,命名卷与容器生命周期不同;卷仍不是备份,不能由容器重建成功推断所有故障可恢复。",
+          "记录一次性进程退出与日志,再用专用标记比较原容器 stop/start 和新容器同一路径;不要把旧卷挂回后的数据误认为可写层被保留。"
+        ],
+        "misconception": "“容器对象存在就代表应用在服务”和“镜像一样就包含上次写入”分别忽略主进程状态和运行时可写层。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "镜像、容器与进程生命周期"
+          },
+          {
+            "lessonId": "05",
+            "label": "MySQL 持久卷与可恢复备份"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "依赖层命中缓存,能直接发布吗?",
+        "prompt": "只改一行商品 API 源码后,依赖层显示 CACHED。同学说依赖一定最新,基础镜像标签相同也代表两次产物完全一样。你仍需记录和验证哪些输入?为什么还要控制复制范围?",
+        "checkpoints": [
+          "先复制依赖锁并安装,再复制源码,可在只改源码时复用依赖层;缓存加快构建,不主动更新依赖,也不保证输入是最新的。",
+          "标签可能移动,真实基础 digest、源码版本、目标平台和完整依赖锁用于固定及追溯输入;这些稳定也不能单靠 Dockerfile 声称字节级完全一致。",
+          "多阶段的 builder 与 runtime 保持基础和架构一致,复制必要环境与应用;运行时实际核对依赖导入、迁移文件和非 root 用户。",
+          "明确构建上下文和 COPY 范围,由忽略文件排除本机秘密、备份和虚拟环境;后来删除误复制秘密不能抹去旧镜像层。"
+        ],
+        "misconception": "“CACHED 意味依赖已更新且安全”把复用当审查;“同标签就是同内容”让发布记录无法对应实际产物。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "为 FastAPI 编写非 Root Dockerfile"
+          },
+          {
+            "lessonId": "03",
+            "label": "分层缓存、多阶段构建与可重复输入"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "宿主能连数据库,为什么 API 容器不能?",
+        "prompt": "宿主在 127.0.0.1:3307 能查商品,API 容器使用相同地址却失败。有人提议公开更多数据库端口,并用 EXPOSE 代替 Compose 端口配置。请按请求所在位置解释地址、端口和验证顺序。",
+        "checkpoints": [
+          "API 容器中的 127.0.0.1 指 API 自己;本课 Compose 内访问数据库使用 mysql:3306,Redis 使用 redis:6379。",
+          "宿主映射 3307 供宿主访问,发布规则左右分别属于宿主和容器;同一 Compose 网络内互通不依赖发布数据库宿主端口。",
+          "EXPOSE 是镜像元信息,不自动开放宿主入口。为修复容器内地址而扩大暴露端口并非必要步骤,也不能修正错误的目标位置。",
+          "从 API 的网络位置依次检查 DNS、目标 TCP 和 app_user 认证查询;宿主成功、容器 running 或 TCP 通畅均不能替代真实数据库查询。"
+        ],
+        "misconception": "“localhost 永远是我的电脑”忽略网络位置;增加公开端口不能修复 API 把自己当成数据库的问题。",
+        "revisit": [
+          {
+            "lessonId": "04",
+            "label": "Compose 网络、服务 DNS 与端口"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "卷还在、备份命令成功,够了吗?",
+        "prompt": "重建 MySQL 容器后专用标记仍可查询,备份也生成了非空文件。两份证据各能证明什么?请设计一个不覆盖原 shop_lab 的恢复检查。",
+        "checkpoints": [
+          "标记保留说明这次正确复用了持久卷;它不能抵御所有磁盘故障、误删或数据写坏,所以卷不能替代备份。",
+          "备份退出成功、文件非空只说明生成了转储;仍需检查导入成功、样本与表引擎符合预期,并验证恢复后查询可用。",
+          "按原课建立独立恢复容器、卷和恢复库,导入后核对 volume_probe;业务表迁移后生成的新备份还要补业务验证,不能覆盖原库和原卷。",
+          "普通复制运行中目录可能没有一致性,同宿主副本也不能覆盖宿主故障;记录恢复范围与实测耗时,不用每日转储自动承诺分钟级数据损失目标。"
+        ],
+        "misconception": "“持久卷等于备份”和“生成文件等于恢复可用”分别漏掉独立副本和恢复验证,可能在故障时才发现唯一真源被覆盖或转储不可用。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "MySQL 持久卷与可恢复备份"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "容器 running 后,还要等哪几道门?",
+        "prompt": "初次启动时 MySQL 已 running,但业务表没建立;运行一段时间后数据库又短暂停止。分别说明启动与运行阶段该观察什么,并判断每个 API 副本启动时都执行迁移是否合适。",
+        "checkpoints": [
+          "数据库探针应完成认证查询;独立迁移作业再确认结构与 revision,成功退出后才让 API 启动并进行自己的就绪检查,running 不能替代这些条件。",
+          "/live 说明应用进程可响应,/ready 验证自己的数据库访问;故障时前者正常而后者失败并不矛盾。",
+          "条件依赖只控制启动前置,不能保证数据库此后持续可用;运行时仍需处理失效连接与有预算的恢复,恢复后核对既有商品及新操作。",
+          "迁移由一个独立作业执行,失败保留日志并阻止发布;每个 worker 各自迁移会竞争锁与版本状态,不能忽略错误后继续接流量。"
+        ],
+        "misconception": "“depends_on 成功一次就永远健康”误把启动顺序当持续保证;“每个副本自己改表最方便”又引入并发迁移冲突。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "健康检查、启动重试与一次性迁移"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "换成非 root,就保护好秘密了吗?",
+        "prompt": "镜像使用 UID 10001,却把密码复制进去再在下一层删除;另一份部署挂载秘密文件,却没让应用读取。为什么都可能失败?你会怎样验证收紧权限后业务仍工作?",
+        "checkpoints": [
+          "非 root 降低运行权限,不会擦除构建时带入的秘密;后层删除不能抹掉旧层,需按课程思路轮换已泄露凭据并重建无该层的镜像。",
+          "运行时秘密文件提供受控位置,应用还需明确支持读取;环境变量名称带 FILE 不代表所有程序自动加载对应文件。",
+          "本地 Compose 秘密来自宿主文件,宿主权限、备份和 Docker 管理权限仍是边界;不向应用挂 Docker socket,也不通过打印连接串证明秘密可读。",
+          "用实际运行用户核对秘密可读、/app 写入被拒绝、受限 /tmp 可写,再验证 /ready 和商品查询;扫描用于定位并更新输入,不能等同安全已经证明。"
+        ],
+        "misconception": "“非 root 是完整隔离”和“文件挂上应用自然会读取”省略不同层面的配置与验证;只看到写入报错也不能说明业务仍正常。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "为 FastAPI 编写非 Root Dockerfile"
+          },
+          {
+            "lessonId": "08",
+            "label": "配置、Secrets 与容器运行权限"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "把完整导出链路放进 Compose",
+        "prompt": "从原课最终任务工程出发,先画 API、fast/bulk worker、publisher、recovery 与存储的职责和连接,再独立做本机验证:暂停目标 worker 后提交专用导出,恢复它并核对结果。不要把早期快照演示当成最终任务实现。",
+        "checkpoints": [
+          "采用最终只接收 job_id 的任务与持久状态表,核对注册名称、参数、目录和依赖;API 与同步任务的 URL 驱动入口不同,但都指向约定数据库。",
+          "fast/bulk 真正各有对应消费者;publisher/recovery 的单步函数要按本课包装成有间隔、错误退避并响应停止信号的循环,不能把执行一次当成持续维护。",
+          "缓存与认证 broker 分实例;输出放在相关 worker 和验证端可访问的明确共享位置。逐角色设置秘密、并发与资源,数据库连接按进程总数预算。",
+          "暂停时确认新任务 queued,恢复后按同一 job_id 核对终态、有效 result_ref 与真实快照文件;同时保留实际资源上限和正常退出日志,不只截图容器 running。"
+        ],
+        "misconception": "“所有后台角色塞进 API 容器最省事”混淆生命周期与资源;“worker SUCCESS”也不能替代持久记录和真实文件的联合验收。",
+        "revisit": [
+          {
+            "lessonId": "09",
+            "label": "API、Worker 与队列的 Compose 整合"
+          },
+          {
+            "lessonId": "07",
+            "label": "CPU、内存、进程数与优雅退出"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "新镜像有问题,怎样证明能退回?",
+        "prompt": "准备一次本机小版本发布,只改商品服务表现,不加新业务。先写发布记录与停止条件,再按原课隔离环境验证;若决定回退旧镜像,必须先确认哪些兼容性,最后交出什么证据?",
+        "checkpoints": [
+          "记录真实旧新镜像标识、源码与基础输入、当前/目标 revision、备份恢复证据及回退窗口;标签不能替代内容标识,未推送不能编造仓库 digest。",
+          "唯一迁移作业先成功,再更新应用角色;失败就停止后续切换,查实际结构与日志,不能用重启掩盖迁移失败。",
+          "先验证旧镜像可在当前数据库结构和语义下读写,才把回退列为选项;删除、重命名或语义变化可能关闭回退窗口,应考虑课程讨论的前向修复。",
+          "回退后检查 /ready、既有商品与一次新导出,保留原卷完整的证据及实际中断时间;不能清空数据库让旧版启动,也不由单机单副本更新承诺零停机。"
+        ],
+        "misconception": "“标签换回就一定恢复”和“删卷或机械 downgrade 就能撤销发布”忽略数据库与已提交数据的独立演进,可能把应用问题扩大为数据丢失。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "镜像版本、发布回滚与故障验收"
+          },
+          {
+            "lessonId": "03",
+            "label": "分层缓存、多阶段构建与可重复输入"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立说明容器化请求经过的进程、网络和存储边界。",
+        "evidence": "不看答案画出宿主、API、MySQL、Redis 连接位置,预测主进程退出、stop/start 与重建对临时文件和卷的不同影响。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立构建并运行包含应用与迁移文件的受限镜像。",
+        "evidence": "交出真实构建输入和镜像标识、实际运行用户、依赖及文件检查,以及不打印内容的秘密读取验证。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立判断启动成功与业务就绪,并验证商品和导出的完整链路。",
+        "evidence": "提供数据库健康、迁移退出、readiness、按 job_id 核对的终态与文件;失败时能指出未过哪一道门。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立验证恢复或兼容回退,而不只写操作计划。",
+        "evidence": "在独立恢复库查到原标记,或在新结构上运行已验证旧镜像完成业务探针;保留原卷,并记录实测恢复结果和未验证边界。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "07",
+    "group": "部署、排障与设计",
+    "title": "Linux 部署:我能独立发布和恢复商品服务吗",
+    "summary": "从登录目标、运行身份到入口、发布和备份,用能够复核的操作结果检查部署是否真正掌握。",
+    "sourceModule": "linux",
+    "intro": {
+      "scenario": "陶瓷杯和帆布袋已经能在开发电脑中查询、导出。现在由你把同一服务交给练习服务器,并为下一次更新或故障留下可用的恢复办法。",
+      "startingPoint": [
+        "已完成 Linux 部署模块,保留独立练习 VM、可用控制台、shop-service 发布目录,以及一次正常商品读写和导出的记录。没有该环境时先完成书面作答,将实操项保留为未验证。",
+        "先独立写出每题判断、检查次序和需要的证据,再展开参考要点;有遗漏时回看指定课时,之后合上答案重新作答。",
+        "实操仅限已核对的隔离 VM 和新建测试数据;沿用课程的管理入口、回退和秘密保护边界。未配置真实域名或通知渠道的能力如实注明,不能凭自评视为已验收。"
+      ],
+      "outcome": "完成一份能区分已解释、已实操和未验证内容的部署自查记录,指出还需重做的具体环节。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "旧 SSH 会话还连着,能收紧登录了吗",
+        "prompt": "你准备禁用密码登录。旧管理会话仍能执行命令,新建的 deploy 账号也已经放入公钥。列出现在还必须完成的验证,并解释每一项防止什么问题。",
+        "checkpoints": [
+          "先核对主机名与主机公钥指纹,确认修改的是目标练习 VM;客户端用于登录的私钥与服务器用于证明身份的主机密钥不是同一把钥匙。",
+          "保留原会话和控制台,在另一终端真正建立 deploy 密钥登录并验证管理权限;已建立的旧连接不能证明新的认证配置可用。",
+          "核对 authorized_keys 所有者与权限,检查 SSH 配置语法和实际生效值;新路径通过后才收紧并重载,不能用关闭主机指纹校验掩盖身份变化。"
+        ],
+        "misconception": "误以为“旧会话可用,所以新登录一定没问题”。旧连接可能在认证规则改变前就已建立,退出后才会暴露无法重新登录。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "SSH、账户与服务目录"
+          },
+          {
+            "lessonId": "02",
+            "label": "监听地址、DNS 与防火墙"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "本机能查商品,浏览器却打不开",
+        "prompt": "VM 内直连 API 的商品查询成功,浏览器访问站点失败。应用只监听回环地址。请画出浏览器到数据库的路径,并按顺序给出需要核对的地址、端口和观察结果。",
+        "checkpoints": [
+          "先区分域名解析、到 VM 的网络路径、Nginx 入口与 API 上游;核对实际 A/AAAA 地址、站点选择及监听端口,不能把所有失败都归到应用代码。",
+          "保留 API 回环监听,通过本机带正确 Host 的站点请求验证 Nginx 到 API;若此处成功再检查主机防火墙和云侧入口,若返回网关错误则查看该站点上游日志。",
+          "宿主进程沿练习配置访问 127.0.0.1:3307,Compose 网络内才使用 mysql:3306;数据库和任务 broker 不因需要网页入口就对公网开放。",
+          "先核对实际 SSH 端口,再调整限定规则并保留回退。HTTPS 还需验证真实域名证书,忽略证书校验的请求不能证明 TLS 正常。"
+        ],
+        "misconception": "误以为把 API、MySQL 和 Redis 全部绑定公网即可解决访问。这样混淆了各层职责,也没有定位 DNS、代理或证书的实际故障。",
+        "revisit": [
+          {
+            "lessonId": "02",
+            "label": "监听地址、DNS 与防火墙"
+          },
+          {
+            "lessonId": "05",
+            "label": "Nginx HTTP 入口与 TLS"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "管理员能运行,为什么服务账号失败",
+        "prompt": "管理员手动导入商品代码成功,shop-api 却无法启动;另一台机器复制来的虚拟环境也在发布目录中。说明应从身份、路径、解释器和配置四方面留下什么检查证据。",
+        "checkpoints": [
+          "使用真实 shop 运行身份核对解释器、依赖导入及目录每一级的进入权限;管理员的成功结果不能替代低权限进程的结果。",
+          "使用目标 Linux/Python 对应的环境和已验证依赖锁,不把开发电脑的虚拟环境直接当作可迁移发布物;检查 current 实际指向的版本。",
+          "发布源码由管理员维护、运行用户只读,导出结果通过共享 artifacts 目录写入;不要为解决一次权限错误把整个项目开放写权限。",
+          "核对 API 与任务各自的环境文件、读取权限和驱动 URL;记录配置来源而不打印密码。主机进程使用实际主机/端口,不沿用仅容器网络能解析的服务名。"
+        ],
+        "misconception": "误以为管理员运行成功已证明部署正确。systemd 使用的身份、工作目录、解释器和配置可能都与手动终端不同。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "Python 环境、配置与最小权限"
+          },
+          {
+            "lessonId": "04",
+            "label": "systemd 管理 API、Worker 与维护进程"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "所有单元 active,就能通知用户已恢复吗",
+        "prompt": "API、fast/bulk Worker 和维护进程都显示 active,但新提交的商品清单还没有文件。请列出从“进程在运行”到“业务恢复”的验证链,并指出迁移和定时器应放在哪里。",
+        "checkpoints": [
+          "先检查 API 真实就绪与商品读写,再按 job_id 核对任务、待发事件、执行状态和结果文件;active 仅描述进程状态,不能证明依赖可达或消息有人处理。",
+          "派发器负责把已保存的意图交给队列,Worker 负责执行,恢复器处理符合条件的遗漏;它们是不同入口,应能分别定位配置和日志。",
+          "迁移使用独立一次性作业,成功并核对实际结构后才推进发布,不能由每个 API 启动时抢着执行。只在已完成周期任务时单独启用一份 beat。",
+          "验收应包括一条新任务完成及原未完成任务的去向;读到 result_ref 后还要确认文件存在且对应固定商品快照。"
+        ],
+        "misconception": "误以为 active 或一条受理响应代表导出成功。受理、派发、执行、发布文件是不同阶段,前一阶段的成功不能代替后一阶段。",
+        "revisit": [
+          {
+            "lessonId": "04",
+            "label": "systemd 管理 API、Worker 与维护进程"
+          },
+          {
+            "lessonId": "10",
+            "label": "完整发布、回退与故障 Runbook"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "直连逐条出现,代理后为什么挤成一批",
+        "prompt": "商品导出进度直连 API 时逐条出现,经过 Nginx 后攒成一批;WebSocket 客户端又连不上。你如何分别验证这两种问题,什么结果才说明修复完成?",
+        "checkpoints": [
+          "先确认实际 /stream 和 /ws 路由已整合,分别建立直连基线;保持请求和客户端读取方式相同,再比较代理后的事件到达情况。",
+          "SSE 检查对应 location 的缓冲设置、空闲读取预算与实际心跳;WebSocket 检查 HTTP/1.1 及 Upgrade、Connection 转发,HTTPS 页面使用 wss。",
+          "Nginx 配置检查通过后再加载;分别保留逐条到达记录、101 握手和双向消息结果,不能只凭页面能打开判断连接正常。",
+          "再验证断线与主动停止。未实现事件编号和续传时,应承认重新连上不能补齐中断期间漏掉的事件。"
+        ],
+        "misconception": "误以为统一加大超时就能修复所有长连接。缓冲、协议升级、心跳与客户端取消是不同环节,需要各自的证据。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "SSE、WebSocket 与代理超时"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "备份文件很大,就代表能恢复吗",
+        "prompt": "备份目录里有一份加密 SQL 文件,商品任务表也曾导出成功。如果原 VM 丢失,你还需要哪些内容和验证,才能说明商品查询与清单下载可以恢复?",
+        "checkpoints": [
+          "确认转储成功退出、使用兼容工具且快照条件满足;校验密文与解密后文件完整性,在独立空恢复库实际导入,不能只看文件大小。",
+          "恢复端必须能取得独立保存的解密私钥和受控数据库凭据;备份 VM 仅持有加密公钥的设计不等于丢失私钥后仍可恢复。",
+          "核对商品数据、迁移版本、任务状态,以及 result_ref 指向的清单文件;恢复应用使用隔离队列或禁用真实外发,避免旧任务盲目重放。",
+          "记录备份恢复点、可能缺失的数据窗口与实际恢复耗时,再判断是否满足约定目标;没有 binlog 等额外恢复证据时,不承诺任意时刻恢复。"
+        ],
+        "misconception": "误以为数据库备份成功就能还原整项服务。文件、密钥、配置和任务恢复语义缺一项,都可能出现记录还在但无法下载或重复执行。",
+        "revisit": [
+          {
+            "lessonId": "09",
+            "label": "数据库备份、异机加密与恢复目标"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "独立完成 r002 发布并证明能回退",
+        "prompt": "在隔离 VM 为商品服务准备一个可区分的 r002 小修改。先独立写出发布、停止条件和回退次序,再利用已学工具执行。保留 r001;不引入新业务,不删除数据库数据。需要交出哪些结果,才能证明切换与回退都真实有效?",
+        "checkpoints": [
+          "准备完整 r002、目标平台环境和共享结果路径,核对 shop 身份可读可执行;记录当前版本、revision、备份证据及新旧代码兼容范围。",
+          "迁移作业先成功,核对实际结构后才切换;同文件系统原子替换 current 后仍需重启各应用角色,因为软链变化不会自动替换运行中的代码。",
+          "用同一件新建测试商品和一条真实导出验收新版本,记录状态码、版本条件、job_id 与可读文件;单副本重启的中断应如实记录。",
+          "仅在兼容范围内切回 r001 并重启相关角色,重复业务验收并确认已提交数据保留;若迁移破坏兼容则停止机械回退,说明为何需要其他修复路径。"
+        ],
+        "misconception": "误以为 current 已指回旧目录就证明回滚完成。旧进程可能尚未替换,而当前数据库结构也可能不再支持旧代码。",
+        "revisit": [
+          {
+            "lessonId": "07",
+            "label": "发布目录、原子切换与兼容回退"
+          },
+          {
+            "lessonId": "10",
+            "label": "完整发布、回退与故障 Runbook"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "让一次派发停顿留下完整恢复证据",
+        "prompt": "先为隔离环境写一个你能解释的“最老待发事件等待过久”阈值,再暂停本次 publisher 并提交一条新商品导出,随后恢复。先独立列出要收集的时间点与记录,完成后据实填写;本题不要求接通真实通知渠道。",
+        "checkpoints": [
+          "注入前证明基线任务可完成,记录具体实例、新 job_id、预期影响及恢复动作;只暂停已核对的 publisher,保持数据库和 Worker 的原状态。",
+          "把事件创建时间、最老待发年龄、publisher 单元状态和相关时间段日志对应起来,区分“已保存任务”与“已送入队列”。",
+          "恢复 publisher 后记录派发、任务终态与文件验收时间,确认不是仅把积压转移到另一个阶段;日志导出不包含环境文件内容或秘密。",
+          "说明告警窗口和日志保留预算,通知未接入时标为手工演练;根据实际发现与恢复用时提出一个有验收条件的改进,而不填写预计成功。"
+        ],
+        "misconception": "误以为写了一条告警规则就已经具备自动监控。阈值、观测来源、处理动作与真实通知渠道分别需要验证。",
+        "revisit": [
+          {
+            "lessonId": "08",
+            "label": "journald、日志轮转与可执行告警"
+          },
+          {
+            "lessonId": "10",
+            "label": "完整发布、回退与故障 Runbook"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立解释一条商品请求经过的身份、网络和数据边界。",
+        "evidence": "不看参考要点画出客户端、Nginx、API、数据库和任务角色,标出实际监听地址、运行身份、配置来源与可写目录,并解释一处连接失败应先查哪层。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立准备并启动一个可验证的新发布版本。",
+        "evidence": "交出一次隔离发布记录:目标路径和解释器、配置权限、迁移结果、运行角色及商品与导出探针;没有照抄他人的成功输出,未执行项单独标明。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立证明业务切换或回退后的结果符合约定。",
+        "evidence": "提供同一测试商品的读写记录、新任务编号、结果文件核对和版本切换前后时间;说明旧代码能否在当前表结构上运行,不以 active 代替业务验收。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立限定故障影响并验证恢复范围。",
+        "evidence": "交出一项受控故障的实际时间线及恢复结果,并提供一份独立恢复库的数据和文件核对记录;能够区分已测恢复时间、可能损失窗口与尚未验证能力。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "08",
+    "group": "部署、排障与设计",
+    "title": "故障排查:我能用证据找到问题并确认恢复吗",
+    "summary": "面对慢请求、缺失结果和连接异常,检查自己能否先定位阶段,再限定动作并验证真实恢复。",
+    "sourceModule": "troubleshooting",
+    "intro": {
+      "scenario": "商品页面有时变慢,导出记录仍显示处理中。现在没有现成的故障名称,需要你把用户现象变成可检验的判断,并把修复与恢复证据分清。",
+      "startingPoint": [
+        "已完成故障排查模块,准备已知正常的隔离 shop-service、副本对应的进程或容器信息,以及一组新建商品和导出测试数据。",
+        "先写判断、证据和下一步,再展开参考要点。题目中的现象是待分析条件,不是你已经采集的实测结果;未执行的检查保持未验证。",
+        "实操沿用课程的请求数、持续时间和资源预算,每次只注入一个故障。SSE/WebSocket 变式需先确认已整合相应路由,否则先标出缺少的前提。"
+      ],
+      "outcome": "形成可反驳假设、可追踪动作和可复核恢复的自查记录,找到最需要重新练习的诊断阶段。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "只有导出投诉时,先查什么",
+        "prompt": "收到“商品清单一直没有下载结果”的反馈,同时有人说商品页面还能打开。你只有进程列表和一条超时报错。请写出最先建立的影响表,以及在动服务前必须补足的证据。",
+        "checkpoints": [
+          "分别验证存活、依赖就绪、商品读取、持久任务受理和最终文件,不用一条超时推断全站不可用;没有实现的 HTTP 导出入口不能当作既有能力测试。",
+          "记录实际实例、UTC 时间、最后正常时间和具体 job_id,分别标明观察事实与候选原因;进程存在不能证明数据库或文件服务可用。",
+          "候选动作应有对应假设、预期改变和恢复前值,先只读检查再做限定动作;恢复判定同时追踪新导出完成及旧任务去向。"
+        ],
+        "misconception": "误以为先重启所有组件总能最快找原因。多个状态同时改变会破坏定位线索,进程恢复后旧任务也可能仍然遗漏。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "先确认影响范围,再选择止损动作"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "请求号、图表和追踪各能证明什么",
+        "prompt": "用户提供一次失败请求的编号,你又看到“五分钟错误率升高”的图表。一名同学建议给每个 job_id 建一个指标标签,并只保留 trace。请说明你怎样关联证据,以及这个建议会丢掉或增加什么。",
+        "checkpoints": [
+          "用 request_id 定位本次请求,再经受理记录关联 job_id;message_id、attempt 或租约版本帮助区分投递与执行。trace 展示阶段关系和耗时,不能替代持久业务状态。",
+          "指标使用有限的路由模板、方法和状态类别;具体请求或任务编号放日志/追踪,逐编号标签会使时间序列数量随业务增长。",
+          "错误比例需要明确有效样本、分母和窗口,无请求时不能报告为零错误率;耗时分位数按课程的直方图方式聚合,不平均各实例 P95。",
+          "追踪可能因采样而缺失,仍应保留业务编号的日志定位链;导出或分享记录前检查敏感字段,不把查询串、完整请求或秘密直接附上。"
+        ],
+        "misconception": "误以为采集越多越完整,或 trace 能取代所有记录。无限编号标签会拖累监控,采样链路也不能保证每个长期任务都能找回。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "定义 SLI、SLO 与低基数指标"
+          },
+          {
+            "lessonId": "04",
+            "label": "把 trace 贯穿 HTTP、数据库与 Celery"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "CPU 不高,为什么商品请求一起卡住",
+        "prompt": "隔离 API 的 CPU 很低,但两次慢请求会串行完成,同一时刻的健康请求也延迟。另一轮实验 CPU 明显升高。你会对比哪些数据,如何一次只改变一个因素来验证原因?",
+        "checkpoints": [
+          "先核对负责本次请求的真实进程,再记录同一有限负载下的 CPU、事件循环延迟、HTTP 耗时及线程等待;不能拿另一个进程的低 CPU 证明应用没有问题。",
+          "异步路由内同步 sleep 可占住事件循环却不忙于计算;计算循环可能同时拉高 CPU 与调度延迟。两种现象相似,但证据组合不同。",
+          "分别恢复可让出的等待,或按课程使用受限线程处理同步 I/O、受限进程处理 CPU 工作;比较相同输入,不同时扩 worker 和改代码,结束后关闭实验入口。"
+        ],
+        "misconception": "误以为函数写了 async 就不会阻塞,或者 CPU 低就只能是网络。异步函数里直接调用同步阻塞操作仍会让其他请求等不到执行机会。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "区分 CPU 忙、事件循环阻塞与线程池等待"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "退出码 137 足够判定内存泄漏吗",
+        "prompt": "容器一次退出码为 137;另一轮里 Python 分配快照已经下降,进程 RSS 仍较高。请分别写出现在能下的结论、不能下的结论和下一项观测。",
+        "checkpoints": [
+          "137 可能来自内存超限终止,也可能是其他 SIGKILL;结合 OOMKilled、cgroup 的 oom_kill 增量、限制值及退出时间判断,不凭单个退出码归因。",
+          "tracemalloc、RSS 和容器内存统计覆盖范围不同;释放 Python 对象后底层分配器可能暂留内存,快照下降与 RSS 未立刻下降并不矛盾。",
+          "对相同有限请求批次保留多轮、同口径数据,观察同一分配位置是否持续增长;若 Python 跟踪稳定而 RSS 增长,再看原生库、缓冲或子进程,不先宣告 Python 对象泄漏。"
+        ],
+        "misconception": "误以为 RSS 不回落就一定泄漏,或宿主有空闲内存就不会 OOM。分配口径与容器限制分别影响这两个判断。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "区分 Python 分配、RSS 与容器 OOM"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "“查询超时”可能发生在哪个阶段",
+        "prompt": "三次商品相关请求分别出现:借连接等待超时、连接到未监听端口失败、数据库锁等待超时。请为每种情况选择最直接的证据与恢复检查,说明为什么不能统一扩大超时。",
+        "checkpoints": [
+          "池等待先看连接上限、占用与获取连接的耗时;连接失败在实际调用的网络环境核对 DNS、地址和监听。一个请求可能尚未接触数据库就耗尽预算。",
+          "数据库锁等待需要连接 ID、活跃事务及谁阻塞谁的关系;诊断账号看不到记录可能只是权限不足,不能把空结果当作没有阻塞。",
+          "MySQL 锁等待超时通常只结束失败语句,隔离两会话实验应明确回滚各自事务;再核对等待消失和商品请求恢复。Sleep 连接也可能持有长事务。",
+          "增加超时可能把错误地址或锁占用转化为更久排队;先定位等待发生处,再在同等负载下检验修复。"
+        ],
+        "misconception": "误以为所有 timeout 都是同一种“数据库太慢”。借连接、建立连接、执行及锁等待有不同证据和处理边界。",
+        "revisit": [
+          {
+            "lessonId": "07",
+            "label": "分开排查 DNS、TCP、TLS 与连接池等待"
+          },
+          {
+            "lessonId": "08",
+            "label": "定位慢 SQL、锁等待与连接池耗尽"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "Worker 在线,旧任务为什么仍不完成",
+        "prompt": "Worker 的检查命令可以响应,但一个商品导出 job 很久仍是 queued。任务消息也出现过重复。请写出排查分支,并说明什么证据才允许你执行恢复。",
+        "checkpoints": [
+          "先并排查持久 job、未发送 outbox、实际队列路由、Worker 注册和活动队列;queued 只能说明当前业务状态,不能证明消息已经到 broker。",
+          "沿同一 job_id 区分事件 ID、消息到达、成功领取与 attempts,核对租约、next_attempt_at 和 deadline;重复消息不应自动等于又执行了一次。",
+          "确认下游和容量恢复后再用有限恢复与派发流程;不能清空队列、重置 attempts 或无预算扩大并发来绕过失败保护。",
+          "恢复后覆盖相关截止时间和扫描周期观察最老等待、完成速率、重试与下游错误,核对新文件和旧任务去向,而不只看队列长度短暂下降。"
+        ],
+        "misconception": "误以为消费者能回应就代表整条任务链正常,或清队列后“积压为零”就是恢复。待发事件可能尚未入队,清除消息还可能丢掉工作。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "定位 Celery 积压、重试风暴与租约恢复"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "给缓存故障做一次可复查的诊断",
+        "prompt": "先独立写好限定影响与恢复步骤,再在隔离环境只暂停缓存 6380,保持任务 broker 和数据库正常。用同一组小规模商品读取比较故障前后,恢复缓存后再次比较。你需要交出怎样的记录,才能支持“回源仍受控”的判断?",
+        "checkpoints": [
+          "明确容器/实例、时间窗口、请求数量、恢复方式与停止条件;先采集正常基线,只暂停缓存,不能用任务 broker 的凭据或故障动作替代。",
+          "比较同长度窗口的命中、未命中、淘汰与数据库请求增量,以及池等待和 HTTP 状态;累计计数应作差,不能把两次总数直接当每秒速率。",
+          "确认缓存错误走有并发和时间上限的回源路径,记录真实数据库压力;超预算时明确失败,不编造商品或无限等待,也不把本进程上限当作全局上限。",
+          "恢复后证明正常命中和商品读取回来,broker 未受影响;按实际证据写出哪个假设成立、哪个被排除,以及未采集项。"
+        ],
+        "misconception": "误以为故障期间所有请求仍返回 200 就证明降级成功。数据库可能承受了不可持续的额外压力,需要容量与等待的证据。",
+        "revisit": [
+          {
+            "lessonId": "09",
+            "label": "排查 Redis 热点、淘汰与缓存降级"
+          },
+          {
+            "lessonId": "12",
+            "label": "完成故障演练、恢复门槛与 runbook"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "定位进度显示延后,并验证真的停止",
+        "prompt": "在已整合 SSE 和 WebSocket 的隔离副本中,选择一条真实代理路径。先独立设计比较方法:同一进度请求在直连、代理和浏览器中分别何时可见;用户主动停止后哪些资源应消失。使用已有探针完成核对,不新增业务。",
+        "checkpoints": [
+          "保持测试输入与读取方式一致,记录首条业务 data 的到达时间;响应头可能先到,因此不能用首字节时间替代首条业务内容时间。",
+          "直连正常而代理攒批时检查对应缓冲与路径;二者正常而页面延后时检查客户端读取和渲染。只按证据修改本次隔离 location,先验证配置再加载。",
+          "WebSocket 单独核对 101、双向消息、关闭码与实际重连;它的升级问题不能直接用 SSE 的缓冲设置解释。",
+          "主动停止后等待超过约定的最大重连间隔,确认无新连接并有服务端清理证据;恢复临时配置,最后再完成一次完整事件流与 echo。"
+        ],
+        "misconception": "误以为停止页面显示或断开一条连接就等于任务停止。浏览器可能自动重连,服务端也可能还在等待或继续占用资源。",
+        "revisit": [
+          {
+            "lessonId": "11",
+            "label": "端到端排查 SSE 缓冲与 WebSocket 断开"
+          },
+          {
+            "lessonId": "12",
+            "label": "完成故障演练、恢复门槛与 runbook"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立把用户现象拆成可检验的阶段假设。",
+        "evidence": "不看答案,为一条商品请求或导出画出影响矩阵,分别写出事实、候选原因和能推翻原因的下一项检查,不先给出未经证实的根因。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立采集足以定位问题且不会误用的证据。",
+        "evidence": "提供一次限定故障中已核对的进程/容器、时间窗口、请求或任务编号以及相关指标、日志或状态表记录;标明单位、累计或增量、合成或实测和权限限制。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立用受控操作检验诊断并保留恢复路径。",
+        "evidence": "完成一次只改变一个因素的实验,记录原配置、请求与资源预算、停止条件及恢复动作;修复前后使用同样输入,出现意外时停止注入。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立证明恢复涉及业务结果而不只是进程状态。",
+        "evidence": "交出商品读写、新导出文件及旧任务去向的恢复证据,覆盖必要观察窗口,并给出一个能通过再次实验验收的改进项;未验证部分保持明确。"
+      }
+    ]
+  },
+  {
+    "kind": "review",
+    "id": "09",
+    "group": "部署、排障与设计",
+    "title": "分布式设计:我能解释取舍并推演失败吗",
+    "summary": "用商品查询与清单导出的短场景,检验状态归属、一致性、重复、容量和恢复的因果判断;全程可以纸面完成。",
+    "sourceModule": "distributed",
+    "intro": {
+      "scenario": "商品目录要由两个 API 副本提供,导出交给独立执行者。增加组件以后,请求会延迟、重复或遇到部分故障;需要你说明每种选择的承诺和代价。",
+      "startingPoint": [
+        "已完成分布式理论模块,准备纸笔或普通绘图工具;沿用商品名称、描述、整数价格、上架状态和清单导出,不增加支付、库存等业务。",
+        "先独立画状态位置、时间线或列计算式,再展开参考要点。每项结论标清题设与假设,不能把纸面预计结果当作实机验证。",
+        "本单元只做理论推演,不要求新代码、额外基础设施或真实故障注入;自查标准用于定位理解缺口,不是客观考试通过证明。"
+      ],
+      "outcome": "给出能说明状态归属、失败窗口、资源预算和恢复边界的简洁设计,并用反例检验自己的承诺。"
+    },
+    "questions": [
+      {
+        "id": "q1",
+        "title": "多开一个 API,哪些问题真的解决了",
+        "prompt": "API-A 在内存字典记录导出进度,下一次查询落到 API-B。两台各能稳定处理 60 次轻请求/秒,平时总流量为 90 次/秒。分别推演查询和 A 故障后的容量,再说明是否必须继续拆成很多微服务。",
+        "checkpoints": [
+          "B 没有 A 的内存记录,可能错误地回答任务不存在;可跨副本查询的任务状态需要共享的可靠记录,“API 无状态”不代表整个系统没有状态。",
+          "一台故障后只剩 60 次/秒能力,对 90 次/秒需求有 30 次/秒缺口;负载均衡和健康检查不会自动创造容量,切换也有发现与在途失败窗口。",
+          "把耗时导出放到独立 Worker 可以单独分配资源,商品模块仍可保留在一个应用中;继续拆分应有独立发布、扩容等具体原因,并承担新网络边界成本。"
+        ],
+        "misconception": "误以为多开副本会自动共享内存、提供足够故障余量,或必须把每个模块做成微服务。副本、状态保存和服务边界是不同选择。",
+        "revisit": [
+          {
+            "lessonId": "01",
+            "label": "从模块化单体到微服务与事件驱动"
+          },
+          {
+            "lessonId": "02",
+            "label": "高可用与负载均衡:多开一台为什么还不够"
+          }
+        ]
+      },
+      {
+        "id": "q2",
+        "title": "两个区域都能接请求,为什么仍不能随意确认修改",
+        "prompt": "两区都保存商品 42 的 version=7,区间网络中断但用户仍可分别访问。东区把名称改成“白杯”,西区改成“透明杯”。比较“只有可联系权威边界的一侧确认”与“两侧都确认”的结果,并说明列表读取可否采用不同约定。",
+        "checkpoints": [
+          "只允许满足协调条件的一侧确认,可以保护同一旧版本不会在两区被各自成功修改;无法满足条件的一侧需要拒绝或等待,部分请求不能正常完成。",
+          "两侧都按本地 version=7 确认后会产生冲突;恢复通信仍需取舍、合并或人工确认,不能回头宣称当时有唯一的跨区域修改顺序。",
+          "列表展示可以在明确时效约定下读较近副本,保存修改则使用保护版本条件的权威边界;副本落后时读到旧值本身不能证明写入已丢。",
+          "网络分区不是把数据库记录分片;即使正常通信,复制仍会有延迟和成本,CAP 也不是平时随意勾选两个功能的菜单。"
+        ],
+        "misconception": "误以为两个区域都能被用户访问就不存在分区,或者稍后最终一致能保证此前每次修改都符合单一顺序。缺失的是区域间协调信息。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "复制与分片:多份副本和分成几份有什么不同"
+          },
+          {
+            "lessonId": "04",
+            "label": "CAP 与一致性:断网时,谁还能回答"
+          }
+        ]
+      },
+      {
+        "id": "q3",
+        "title": "改价并删缓存,为何旧价仍能回来",
+        "prompt": "A 读到商品旧价格 1990、version=7 后暂停;B 写入 2490、version=8 并删除缓存;A 随后回填。写出最终可能看到的库与缓存,再说明延迟再删一次是否已经形成绝对保证。",
+        "checkpoints": [
+          "数据库可以是 2490/v8,缓存却被 A 回填成 1990/v7;按次序更新数据库再删除缓存仍存在这个并发交错窗口。",
+          "再删除一次可能缩小部分窗口,但把 A 的暂停拉长仍可能在第二次删除后回填;固定延迟不能证明所有慢请求已经结束。",
+          "应说明该商品展示能容忍多旧的数据,并按课程选择版本条件、可靠失效及受控回源等组合;不能把容忍旧读与必须保护版本的修改混成同一承诺。",
+          "另行区分不存在编号、单一热点过期和大批键过期的压力来源,分别选择校验或负缓存、合并回源、打散到期及容量保护,不用双删回答所有缓存问题。"
+        ],
+        "misconception": "误以为写后删除或延迟双删能无条件保证缓存永不旧。它们的效果受并发时序影响,必须明确可验证的保障范围。",
+        "revisit": [
+          {
+            "lessonId": "05",
+            "label": "缓存的四类难题:旧值、穿透、击穿与雪崩"
+          }
+        ]
+      },
+      {
+        "id": "q4",
+        "title": "提交超时之后,究竟该不该再建一份任务",
+        "prompt": "商品导出任务和待发送记录已在数据库同次提交,但响应丢失。用户立即重试,发布器也可能重发同一事件;次日用户又主动导出同样商品。请分别判断这三种重复,并指出 outbox 没有替你保证什么。",
+        "checkpoints": [
+          "第一次提交与响应丢失后的重试属于同一意图,应沿稳定意图编号找回同一任务;次日主动发起的新需求通常使用新编号,不能只按正文哈希吞掉它。",
+          "任务与待发送记录同事务保存,使受理事实和派发意图一起保留;发布器发送成功但来不及确认时仍可能重发,因此接收和执行需要自己的业务保护。",
+          "两个 API 同时受理同一意图时,先查不存在再写不够,应由权威存储的唯一约束与事务决定创建结果;去重记录保留时间需覆盖接口承诺。",
+          "outbox 已发送不等于 Worker 完成,更不保证外部文件写只发生一次;远端结果未知时优先按稳定对象标识或幂等语义核实,不能把超时一律当失败重做。"
+        ],
+        "misconception": "误以为 outbox 就是恰好执行一次,或内容相同都该去重。持久派发意图、重复交付与用户的新意图属于不同边界。",
+        "revisit": [
+          {
+            "lessonId": "07",
+            "label": "跨系统事务:本地事务、Outbox 与 Saga"
+          },
+          {
+            "lessonId": "08",
+            "label": "幂等与未知结果:超时以后要不要再来一次"
+          }
+        ]
+      },
+      {
+        "id": "q5",
+        "title": "旧执行者恢复时,新序号能挡住哪些写入",
+        "prompt": "A 的租约到期后 B 接管,A 随后从暂停中恢复。A 序号 41,B 序号 42。分别推演资源已接受 42 和资源尚未见过 42 时 A 的写入;再说明释放锁与发布终态各需什么保护。",
+        "checkpoints": [
+          "租约到期不意味着 A 的进程停止,所以 A、B 可能同时运行;A 释放资格时须原子核对所有者,不能删掉 B 的锁。",
+          "资源已经接受并记录 42 后,应拒绝 41;检查序号、更新最大值与写数据需在同一可靠原子边界内,避免检查后被插队。",
+          "若资源只拒绝小于已见最大值而尚未见 42,仍可能接受 41;这种 fencing 不自动保证租约一过期就拒绝所有旧写,承诺更强时需额外的权威有效版本验证。",
+          "终态可用状态和版本条件限制发布,计算阶段允许重复但控制成本;已发出的远端副作用仍需自己的核实或幂等保护,不能靠旧进程主动停止倒追回去。"
+        ],
+        "misconception": "误以为锁过期会杀掉旧进程,或者 fencing 能无条件阻止一切过期写。必须说明资源已经见过什么序号,以及真正写入在哪个边界验证。",
+        "revisit": [
+          {
+            "lessonId": "09",
+            "label": "分布式锁与租约:过期后旧 Worker 为什么还在写"
+          }
+        ]
+      },
+      {
+        "id": "q6",
+        "title": "队列加长后,用户需要再等多久",
+        "prompt": "导出每秒到达 7 个、完成 5 个,持续 30 秒;高峰后仍每秒到达 3 个。先估算积压和清空时间,再比较两层调用各最多尝试 3 次时的最坏下游请求数,说明应怎样限制新增压力。",
+        "checkpoints": [
+          "高峰净积压为每秒 2 个,30 秒新增约 60 个;假设耗时稳定、无其他瓶颈,高峰后净消化为每秒 2 个,约需 30 秒清空。",
+          "不能用积压直接除以总处理能力,因为新任务仍在到达;还应看最老任务年龄、耗时波动和文件字节吞吐,计算值只是带假设的估计。",
+          "两层各最多 3 次尝试,最坏下游可被调用 9 次;重试会消耗剩余时间与资源,应选择合适层主导,并包含连接、等待和退避的总预算。",
+          "队列只吸收短时突发,超过用户可接受等待时应限制受理或准确降级;给导出独立资源预算可以保留商品查询,恢复后逐步放量。"
+        ],
+        "misconception": "误以为队列越长越可靠,或每一层自行重试都只增加一点压力。长期供给不足不会因排队消失,多层尝试次数还会相乘。",
+        "revisit": [
+          {
+            "lessonId": "10",
+            "label": "超时、重试、熔断与隔离:阻止局部故障扩散"
+          },
+          {
+            "lessonId": "11",
+            "label": "限流、背压与容量:队列只能借时间"
+          }
+        ]
+      }
+    ],
+    "challenges": [
+      {
+        "id": "c1",
+        "title": "为商品清单画一条完整的失败可恢复路径",
+        "prompt": "只做纸面设计:用户选择少量商品提交导出,关闭页面后还能回来查询并下载,也能请求取消。先独立画最小方案,给定你自己的教学用数量上限和完成时限,再在消息确认、Worker 执行和文件返回三个位置插入失败。不要新增订单或支付系统。",
+        "checkpoints": [
+          "图中明确商品与固定快照、任务状态、派发意图、队列、Worker 和结果文件的归属;提交时保存可继续恢复的事实,文件就绪后才发布可下载引用。",
+          "分别推演早确认后未执行、完成后确认丢失、文件结果未知等窗口;每个恢复箭头都应有持久记录或可查询证据,不能只标“重试”。",
+          "重复投递使用同一业务身份并保护终态,取消必须阻止不符合状态条件的成功发布;同商品逐条动作若要求顺序,应明确局部顺序范围,不以最新版本过滤代替所有动作。",
+          "写出输入、并发或受理上限与完成目标,记录最老等待和完成耗时等观测;说明资源不足时用户看到什么,以及哪些承诺需实机验证才能确认。"
+        ],
+        "misconception": "误以为把所有组件画全就完成设计。没有状态归属、失败后的证据和恢复条件,图上的重试箭头无法说明工作会怎样继续。",
+        "revisit": [
+          {
+            "lessonId": "06",
+            "label": "消息队列:可靠交付、重复与顺序"
+          },
+          {
+            "lessonId": "12",
+            "label": "可观测性、容灾与一道完整的系统设计题"
+          }
+        ]
+      },
+      {
+        "id": "c2",
+        "title": "区域恢复后,哪一项能力应先开放",
+        "prompt": "只做纸面变式:恢复副本只包含某个时间点的任务数据,部分任务显示完成但清单文件尚未找回,另一批显示处理中;可用资源暂时有限。请排出恢复与开放服务次序,说明损失窗口、用户提示和不能立即重放的原因。",
+        "checkpoints": [
+          "先确认权威数据恢复点、当前写入归属及配置密钥可用性,再恢复入口和任务协调执行;落后副本不等于历史备份,也不保证包含所有已确认变化。",
+          "核对已完成记录与 result_ref 文件,处理中记录与外部可能已发生的效果;文件缺失不能显示可下载成功,未知效果不能无条件重做。",
+          "资源有限可先恢复商品与任务查询,明确暂缓批量导出;异步重放仍需保留业务标识、版本与恢复规则,防止旧执行结果覆盖或产生重复副作用。",
+          "从恢复点估计可能丢失的数据窗口,从恢复到可用所需步骤估计时间,并分别对照 RPO/RTO 目标;题面没有实测,不能把预算或预计时间写成已达到的保证。"
+        ],
+        "misconception": "误以为任务表恢复完成就可立即开放全部服务并重发所有未完成任务。文件、权威写入位置与外部效果还未核实,重复和虚假成功都可能出现。",
+        "revisit": [
+          {
+            "lessonId": "03",
+            "label": "复制与分片:多份副本和分成几份有什么不同"
+          },
+          {
+            "lessonId": "12",
+            "label": "可观测性、容灾与一道完整的系统设计题"
+          }
+        ]
+      }
+    ],
+    "mastery": [
+      {
+        "id": "m1",
+        "text": "我能独立解释增加副本、拆分进程和复制数据分别改变什么。",
+        "evidence": "不看答案画出同一商品查询与导出的状态归属,使用一个副本故障或读到旧值的反例说明各自解决的问题、剩余限制及新增网络成本。"
+      },
+      {
+        "id": "m2",
+        "text": "我能独立用时间线检验一致性和重复处理承诺。",
+        "evidence": "纸面列出缓存旧值回填、响应丢失或旧 Worker 恢复中的至少两种交错,标出每次读写、证据与原子边界,并指出结论依赖的假设。"
+      },
+      {
+        "id": "m3",
+        "text": "我能独立计算容量和等待,而不把估计写成保证。",
+        "evidence": "用一组不同于课件的到达率、处理率和持续时间,写出净积压、净消化及重试放大计算,注明单位、稳定耗时假设和最大等待超过目标时的处理。"
+      },
+      {
+        "id": "m4",
+        "text": "我能独立给出带失败恢复和明确取舍的轻量方案。",
+        "evidence": "交出一页商品清单导出设计,含正常路径、两个失败分支、可查询证据、恢复先后与降级提示;能用反例说明不能保证什么,全程无需新代码或真实故障注入。"
+      }
+    ]
+  }
+]

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1084
public/content/tasks.json


Dosya farkı çok büyük olduğundan ihmal edildi
+ 329 - 156
public/content/troubleshooting.json


Dosya farkı çok büyük olduğundan ihmal edildi
+ 428 - 189
public/downloads/api-database-lessons.md


BIN
public/downloads/api-lab.zip


Dosya farkı çok büyük olduğundan ihmal edildi
+ 508 - 176
public/downloads/api-lessons.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 644 - 222
public/downloads/database-lessons.md


+ 1923 - 0
public/downloads/distributed-lessons.md

@@ -0,0 +1,1923 @@
+# 分布式系统设计
+
+术核 AI · 后端工程课件
+
+## 01 从模块化单体到微服务与事件驱动
+
+画出一个应用加独立导出进程的方案,并说明何时值得继续拆分。
+
+### 从一个问题开始
+
+商品能保存、清单也能导出后,团队准备增加服务器。应该先把哪部分独立运行?
+
+**这次的终点**
+
+画出一个应用加独立导出进程的方案,并说明何时值得继续拆分。
+
+#### 开始前,确认你在这里
+
+- 已完成商品接口、数据库与异步任务模块,能画出一次商品导出的实际执行顺序。
+- 本模块只在纸面推演;准备一张纸,把 API、任务记录和文件画出来即可,不操作生产系统。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 画出新增商品到下载清单的完整路线
+- 指出哪些箭头需要经过网络
+- 为一次拆分写出收益和新增成本
+
+### 一步步推演场景
+
+#### 1. 固定一条用户旅程
+
+把“新增教学水杯→选择商品→导出→稍后下载”写成六个框。先不命名架构,为每个框填输入、输出,以及结果最终保存在哪。
+
+**对照你的推演**
+
+图中应能找到商品记录、导出时固定的数据和文件;只保存一个“已受理”响应还不能下载清单。
+
+**从这里理解原因**
+
+用户新增教学水杯,填写名称、描述和整数价格,再选择几件商品导出清单,随后查询进度并下载文件。链路依次是:入口校验 → 保存商品 → 固定导出快照 → 执行任务 → 保存文件 → 更新状态并通知。先解释每个框输入什么、输出什么、谁保存事实。
+
+#### 2. 画出模块化单体版本
+
+给上一步的商品管理、分类、评价、导出受理和任务查询画同一个应用边框;把数据库、Redis、文件放到外面,分别标出跨框箭头。
+
+**对照你的推演**
+
+应用内部调用不经过网络;访问库、缓存和文件服务的箭头跨出进程。复制整个应用不会自动增加业务边界。
+
+**从这里理解原因**
+
+把商品管理、分类、评价、导出受理和任务查询放在一个应用边框内,数据库、Redis 和文件存储画在边框外。模块间箭头是进程内调用,跨出边框的箭头需要网络。多开两个应用副本仍可以是单体。
+
+#### 3. 只拆一个有理由的边界
+
+现在新增一个条件:导出计算比商品查询慢得多。只移动负责计算的框,比较移动前后能否单独增加执行进程。
+
+**对照你的推演**
+
+API 继续受理和查询,独立 Worker 消费任务;可以只增加 Worker,暂时无需拆商品和分类服务。
+
+**从这里理解原因**
+
+假设商品清单导出很耗时,需要独立增加执行资源,可以把执行器作为独立 Worker 进程,经队列接收任务。这里先得到“API + Worker”,未必需要拆成多个微服务。仅当商品查询或通知确实出现独立发布和扩容需求时,再考虑新的边界。
+
+#### 4. 比较同步调用与事件通知
+
+分别画“查看商品详情”和“导出完成后通知页面”的时序。把页面必须等待的箭头用实线、可稍后发生的通知用虚线。
+
+**对照你的推演**
+
+详情读取需要即时结果;完成通知可以延迟。等待链减少后,通知失败与重复需要独立处理。
+
+**从这里理解原因**
+
+商品详情和任务查询需要立即返回当前数据,适合同步读取;导出完成后的进度通知可以接收“商品清单已导出”事件。同步链条越长,用户越容易被最慢依赖拖住;异步解耦后仍要处理重复、延迟和失败。
+
+#### 5. 写出选择理由和转向条件
+
+假设三个人维护全部代码,写下本轮保留同一应用的模块、独立运行的角色,以及一个未来值得再拆分的触发条件。
+
+**对照你的推演**
+
+方案可从单应用加独立导出进程开始;每个新增边界都应对应具体扩容或发布需求,而非仅增加服务数量。
+
+**从这里理解原因**
+
+对于三人维护、需求仍在变化的商品服务,模块化单体加独立导出执行器是合理起点。记录“商品查询需要独立扩容”“通知需要独立发布”等未来拆分条件。新增组件时说明具体收益和维护成本。
+
+### 检查推演结果
+
+- 能指出模块边界与网络边界,并说明两者不一定重合。
+- 能用一个具体业务原因解释为什么拆服务,或为什么暂时保持单体。
+- 能区分“执行商品清单导出”的命令与“商品清单已导出”的事件。
+
+### 常见误区
+
+#### 每个功能都拆成服务,改一个字段要协调多个发布。
+
+原因:按技术名词切分,没有确定稳定的业务责任与数据归属。
+
+处理:先整理模块接口和数据所有权,再依据独立变化、资源或权限需求拆分。
+
+#### 加了消息队列后,页面永远不知道任务有没有完成。
+
+原因:只画事件流,没有业务状态和查询入口。
+
+处理:为长任务定义编号、进度、终态和结果引用,让异步过程对用户可解释。
+
+### 面试问答
+
+什么时候应该从单体迁移到微服务?
+
+先指出现有痛点是否来自独立发布、组织边界、扩容或权限隔离,再判断清晰的数据所有权是否已经存在。微服务可以缩小某些变更和资源影响,也会引入网络失败、跨服务一致性与运维成本。能用模块化或独立 Worker 解决的问题,不必靠全面拆分解决。
+
+追问:
+
+- 单体能否横向扩容?为什么?
+- 同步微服务链与事件驱动链分别如何向用户表达失败?
+
+### 自己设计一次
+
+一个三人团队的商品服务只有清单导出缓慢,其他接口负载很低。给出最小合理架构,并列出两个未来拆分服务的触发条件。
+
+提示:把发布复杂度、计算资源和用户等待分别考虑。
+
+参考答案:
+
+保留模块化 API,由数据库保存商品与导出任务,队列把导出交给独立 Worker,文件存入共享存储,用户用任务编号查询进度。API 和 Worker 分别分配资源。未来商品查询需要独立扩容,或通知功能有独立发布需求时,再考虑拆分。新增队列也带来交付和恢复成本;如果承诺关闭页面后仍能完成,就需要持久受理与恢复机制。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 模块化单体:同一家公司里的不同部门
+
+回看“画出模块化单体版本”的推演。商品管理、商品分类、商品评价、清单导出和进度查询可以是同一应用里的清晰模块。它们通过接口协作,应用整体发布,也可以运行多个副本。单体描述部署边界,模块化描述职责边界;一个应用并不等于所有逻辑写在一个文件。
+
+#### 微服务:部门成为独立经营的团队
+
+回看“只拆一个有理由的边界”的推演。把商品查询、清单导出或通知拆成独立服务,可以分别发布和扩容。一次函数调用变成网络请求后,必须面对延迟、超时、版本兼容和局部失败。拆分的价值应来自独立变化或资源需求,服务数量本身不是质量指标。
+
+#### 事件驱动:完成一件事,再通知关心它的人
+
+回看“比较同步调用与事件通知”的推演。“商品清单已导出”是已经发生的事实,进度通知可订阅这一事件;“请导出商品清单”则是请求某个执行者做事的命令。事件驱动描述协作方式,可以和单体或微服务组合;下游稍后完成时,需要显式状态和查询入口。
+
+#### 商品服务的技术责任
+
+回看“固定一条用户旅程”的推演。贯穿案例只管理商品、分类和评价,并支持商品清单导出与进度通知。服务端负责输入校验、持久状态、任务执行和结果交付;商品价格使用非负整数 price_cents,0 表示免费教学样品。业务保持简单,把注意力放在接口、数据和失败恢复上。
+
+### 延伸阅读
+
+- [Microsoft:常见 Web 应用架构与单体边界](https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures)
+- [Microsoft:微服务架构](https://learn.microsoft.com/en-us/azure/architecture/microservices/)
+- [Microsoft:事件驱动架构](https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven)
+
+---
+
+## 02 高可用与负载均衡:多开一台为什么还不够
+
+推演副本故障后的请求去向、数据可见性和容量缺口。
+
+### 从一个问题开始
+
+用户提交导出后,第二次查询落到另一台 API,页面却说任务不存在。多开一台为何出现新问题?
+
+**这次的终点**
+
+推演副本故障后的请求去向、数据可见性和容量缺口。
+
+#### 开始前,确认你在这里
+
+- 完成第 01 课,已有 API 与 Worker 的边界图。
+- 沿用同一张图,给 API 画出 A、B 两份,把任务目前保存的位置圈出来。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 让两台 API 查询到同一任务
+- 算出坏一台后还差多少处理能力
+- 为暂不可用的导出写出准确提示
+
+### 一步步推演场景
+
+#### 1. 从两次请求看状态归属
+
+画 API-A、API-B,给 A 放一条导出记录。让用户先提交到 A,再查询 B;分别试一次“记录只在 A 内存”和“记录在共享库”。
+
+**对照你的推演**
+
+内存方案中 B 查不到;共享记录方案中 B 可返回同一 job_id 的进度,前提是共享库可达。
+
+**从这里理解原因**
+
+用户向 API-A 提交任务,下一次查询被分配到 API-B。若 B 读取共享任务库,可以继续回答;若 A 只写自己的字典,B 会误报不存在。由此理解为何负载均衡之前必须先明确数据存放位置。
+
+#### 2. 推演一次副本不可用
+
+在两次健康探测中间让 A 停止响应。沿在途请求与后续请求分别画箭头,再比较读取重试和提交重试。
+
+**对照你的推演**
+
+探测发现前仍可能有请求失败;查询可再读,提交需沿用同一意图编号,切到 B 不能再创建一份。
+
+**从这里理解原因**
+
+A 停止响应,健康检查在下一次探测后才会发现,流量切换存在延迟,部分在途请求可能已经失败。客户端可按操作语义重试:查询通常容易重试,提交任务需要同一幂等键,避免在 B 再创建一份任务。
+
+#### 3. 检查切换后的承载能力
+
+写下 A、B 各 100 次/秒,总需求 160 次/秒。划掉 A,计算 B 的缺口,选择扩容、限制受理或降级的一种补救。
+
+**对照你的推演**
+
+故障后差 60 次/秒;即使入口立即发现故障并切换,剩余副本仍无法维持原服务目标。
+
+**从这里理解原因**
+
+假设两个副本各能稳定处理 100 个轻请求/秒,平时总流量为 160。一个副本故障后,剩下 100 的能力无法承担 160。冗余之外还需要容量余量、扩容或降级;健康检查不会制造新的计算资源。
+
+#### 4. 区分存活与可以接客
+
+保持 B 的进程运行,但让它无法从数据库取得连接。分别判断“进程存在”和“能完成商品查询”两个问题。
+
+**对照你的推演**
+
+进程存在可以为真,业务就绪可以为假;共享依赖故障时所有副本都可能受影响,反复重启不一定有用。
+
+**从这里理解原因**
+
+进程仍活着,只能证明某个基础条件成立;数据库连接池耗尽时,它可能无法完成业务。健康判断应帮助入口选择可服务实例,同时避免把共享依赖的一次波动变成所有实例反复摘除和重启。
+
+#### 5. 把降级结果写成用户能理解的话
+
+设文件服务停止、商品库正常。分别为商品查询、旧任务查询和新导出写一条用户会看到的结果。
+
+**对照你的推演**
+
+查询可继续,导出明确等待或暂不受理;缺少文件的任务不能显示可下载成功。
+
+**从这里理解原因**
+
+文件存储不可用时,系统可以继续展示商品和查询现有任务,暂缓新导出并给出明确状态。不能把缺失文件标成成功。可用性目标按商品查询、导出受理和导出完成分别定义。
+
+### 检查推演结果
+
+- 能指出两个 API 副本仍共享哪些故障风险。
+- 能算出失去一个副本后,剩余容量是否足够。
+- 能说明健康检查有发现延迟,无法挽救已经中断的请求。
+
+### 常见误区
+
+#### 切到另一台机器后用户任务消失。
+
+原因:商品或任务只保存在本机内存,负载均衡只转发请求。
+
+处理:让可恢复的业务状态进入共享持久存储,连接中断通过业务编号续查。
+
+#### 故障时所有流量涌向剩余副本,随后全部超时。
+
+原因:副本数量增加了,但没有给故障场景预留容量。
+
+处理:按故障后的容量做预算,配合限流、隔离和明确的降级行为。
+
+### 面试问答
+
+部署两个实例是否就实现了高可用?
+
+没有。需要检查入口是否冗余、实例是否跨共同故障域、状态是否可接替、依赖是否存在单点,以及故障后剩余容量是否足够。健康检查和切换也有时间窗口,用户侧需要按请求语义恢复。最终用业务可用性目标评价整条路径。
+
+追问:
+
+- 长连接和普通短请求对负载分配有何不同?
+- 为什么只检查进程存活可能得到错误的健康结论?
+
+### 自己设计一次
+
+商品服务 有三台 API,每台稳定容量为 80 请求/秒,日常流量为 180。要求任意一台故障后仍承接全部流量,应如何调整?
+
+提示:先计算 N−1 场景的剩余容量,再判断是否所有请求必须保留同等服务水平。
+
+参考答案:
+
+失去一台后仅剩 160 请求/秒,小于 180,因此现方案不满足目标。可提升每台稳定容量到至少 90,但这只刚好满足数学下限,实际还要留延迟和突发余量;也可增加到四台,失去一台后理论容量为 240。若预算不允许,可在故障时优先保障查询与取消,限制新导出请求,并把这种降级写进服务目标。还需确认数据库和文件存储吞吐能承接,增加 API 并不自动扩大下游容量。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 负载均衡:把工作分给合适的窗口
+
+回看“从两次请求看状态归属”的推演。入口将请求分给多个 API 副本。轮询适合成本接近的请求;最少连接或考虑负载的策略可改善某些长短任务混杂的情况。一次清单导出可能占用几十秒,一次状态查询只需很短时间,请求个数相等并不意味着工作量相等。
+
+#### 高可用:坏掉一部分后仍满足用户目标
+
+回看“检查切换后的承载能力”的推演。两个 API 副本能减小单进程故障影响,但若都依赖一台数据库,数据库故障仍会影响全站。必须沿入口、应用、存储和依赖逐层观察。高可用是整条业务路径的性质,不是某个盒子旁写“×2”就完成。
+
+#### 故障域:会一起失效的范围
+
+回看“推演一次副本不可用”的推演。同一主机上的两个进程可能随机器一起停止;不同主机也可能共用电源、网络、机房或错误配置。跨故障域布置副本可以降低共同失效风险,却需要承担额外网络延迟、费用和数据同步成本。
+
+#### 无状态副本不等于系统没有状态
+
+回看“从两次请求看状态归属”的推演。让任何 API 副本都能从持久存储读取商品和导出任务,才能随时接替工作。若任务只在 API-A 的内存,切换到 API-B 会查询不到。流式连接仍属于一个具体实例;断开后的恢复需要业务编号和可续接协议。
+
+### 延伸阅读
+
+- [Google SRE:前端负载均衡](https://sre.google/sre-book/load-balancing-frontend/)
+- [Google SRE:数据中心内负载均衡](https://sre.google/sre-book/load-balancing-datacenter/)
+- [Google SRE:处理过载](https://sre.google/sre-book/handling-overload/)
+
+---
+
+## 03 复制与分片:多份副本和分成几份有什么不同
+
+分别用复制和分片解释旧读与容量问题,并列出扩容时必须迁移的工作。
+
+### 从一个问题开始
+
+导出已完成,刷新页面却仍显示处理中;另一方面,一台数据库快装不下所有记录了。
+
+**这次的终点**
+
+分别用复制和分片解释旧读与容量问题,并列出扩容时必须迁移的工作。
+
+#### 开始前,确认你在这里
+
+- 完成第 02 课,能说明共享任务记录为何不能只放某台 API 内存。
+- 画一份任务表,标出一条记录的写入位置和查询位置,以下数字均为纸面示例。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 追踪一次写后读到旧状态的原因
+- 按常见查询选择记录放置规则
+- 找出新增分片仍解决不了的热点
+
+### 一步步推演场景
+
+#### 1. 跟踪一次写后立即读
+
+写出主库 running→succeeded 的时间,再让只读副本晚 2 秒应用它。安排一次落在这 2 秒内的查询并写出页面状态。
+
+**对照你的推演**
+
+用户可能看到 running,即使主库已完成;再次看到旧值本身不能证明写入丢失。
+
+**从这里理解原因**
+
+任务在主库已经从 running 变为 succeeded,但查询被路由到尚未回放的只读副本,用户看到 running。不是写入一定丢了,而是读到了较旧视图。可让关键写后读取走权威节点,或携带版本并等待达到该版本。
+
+#### 2. 比较同步和异步的代价
+
+为一笔任务更新画两种确认时刻:等待远端保存后确认,以及本地保存后立即确认。随后在远端收到更新前让主库不可用。
+
+**对照你的推演**
+
+等待远端需要额外延迟且可能阻塞写入;不等待时,切换到落后副本可能失去部分已确认变化。
+
+**从这里理解原因**
+
+若每次确认都等待远端副本,用户要承担网络和存储等待时间;远端无法响应时,写入可用性也受影响。若不等待,切换到落后的副本可能丢失已确认但未复制的变化。选择时要说明数据损失容忍度和延迟目标。
+
+#### 3. 从查询问题反推分片键
+
+把“本用户最近任务”和“全站导出数量”各写成一张查询卡。尝试按 user_id 分配记录,数出每张卡要访问几个位置。
+
+**对照你的推演**
+
+用户任务查询可落在一个分片;全站统计需要跨片或独立汇总,选键必须考虑查询形状。
+
+**从这里理解原因**
+
+商品服务 最常见的请求是“查询本用户最近的任务”。按用户分片可让请求只去一个位置,路由简单且更容易做用户隔离;若还需要全站导出统计,则应考虑单独汇总视图,而不是让每次请求扫所有分片。
+
+#### 4. 识别分片后仍存在的热点
+
+假设用户 A 贡献 60% 的导出。画四个按用户分配的分片,再新增一个空分片,观察 A 的记录是否自动变少。
+
+**对照你的推演**
+
+新增空分片不会减轻 A 所在分片;拆大用户或限制配额时还要处理原来同用户查询和事务。
+
+**从这里理解原因**
+
+若一个高频导出用户贡献了 60% 的任务,增加其他分片不会自动减轻它所在分片的压力。可以为大用户单独分片、再按子键细分或隔离其配额,但需要重新考虑同用户事务和查询如何合并。
+
+#### 5. 把扩容当作数据迁移问题
+
+把分片从 4 个增加到 5 个,列出搬迁时“读旧位置、读新位置、同时写入、切换路由”的先后与校验点。
+
+**对照你的推演**
+
+只改取模公式会让许多记录被查询到错误位置;新旧路由、迁移进度与数据核对都需要明确协议。
+
+**从这里理解原因**
+
+新增一个分片后,必须决定谁搬过去、搬迁期间读哪边、如何处理同时写入、何时切换路由以及如何确认无遗漏。直接把取模分母从 4 改成 5,会让大量键映射到新位置;一致性哈希减少部分迁移,也不能替代迁移协议。
+
+### 检查推演结果
+
+- 能说明“3 个分片,每片 2 个副本”包含 6 个数据实例,但并非 6 份完整数据。
+- 能解释读到旧任务状态与写丢失之间的区别。
+- 能为一个分片键列出方便的查询、困难的查询和潜在热点。
+
+### 常见误区
+
+#### 加了很多只读副本,写入仍然很慢。
+
+原因:写入瓶颈还在主节点、锁竞争或存储,读副本没有分担该写入路径。
+
+处理:先确认瓶颈;通过事务缩短、索引和数据模型等办法优化,确有单节点容量限制再考虑分片。
+
+#### 所有副本都有同样的误删记录。
+
+原因:把复制当作可回溯的备份,错误变化也被同步了。
+
+处理:分别设计副本接替与历史恢复,明确恢复点以及备份依赖。
+
+### 面试问答
+
+分库分表之前应该考虑什么?
+
+先证明单节点容量或吞吐已成为实际瓶颈,再列出主要查询、事务边界、增长与热点分布。选择分片键时同时讨论数据局部性和负载均匀性,最后补充路由、迁移、跨片查询、唯一标识和恢复成本。读副本主要分担读取,无法自动解决写入热点。
+
+追问:
+
+- 按时间分片为什么容易出现最新分片热点?
+- 分片后跨用户报表如何设计?
+
+### 自己设计一次
+
+一个 商品服务有 1 万个用户,90% 查询按用户列任务,其中一个用户占 40% 写入。应怎样选择分片策略,并解释备份是否还能省掉?
+
+提示:先利用主要访问模式,再单独处理不均匀的超大用户。
+
+参考答案:
+
+可用用户路由表把普通用户分散到分片,使单用户查询保持局部;给超大用户独立分片和配额,必要时再按任务或时间细分。细分后需要额外的列表合并、索引和跨片操作设计,不能宣称已消除所有热点。全站统计可由异步汇总视图承担,并说明延迟。每个分片仍需合适的副本与备份:副本改善接替,备份保留误删前的历史,两者解决不同问题。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 复制:同一份账本的多个副本
+
+回看“跟踪一次写后立即读”的推演。主副本把变化发送给其他副本,主要服务于读扩展和故障接替。异步复制先向用户确认,再让其他副本追上;同步确认要等待约定的副本达到约定阶段。等待接收、持久化和回放不是同一个条件。
+
+#### 分片:每个分柜保存不同的一部分
+
+回看“从查询问题反推分片键”的推演。按用户把导出任务分到不同数据库,各分片只承担部分数据和写入。一个分片内部又可以有多个副本,因此复制和分片可以同时使用。分片引入路由、扩容迁移、跨片查询和事务的额外复杂度。
+
+#### 分片键决定哪些工作能待在一起
+
+回看“从查询问题反推分片键”的推演。按 user_id 分片有利于读取同一用户的导出任务,但超大用户可能成为热点。按 job_id 均匀分散写入,则列出一个用户的全部任务可能需要跨片汇总。没有同时适合所有读写模式的万能分片键。
+
+#### 副本不是历史备份
+
+回看“把扩容当作数据迁移问题”的推演。复制通常也会把误删或坏数据传过去。备份保存可恢复的历史版本,帮助回到错误之前;副本帮助快速接替近期运行状态。前者关心恢复点和保留周期,后者关心同步进度与接管方式。
+
+### 延伸阅读
+
+- [PostgreSQL:主备、流复制与同步复制](https://www.postgresql.org/docs/current/warm-standby.html)
+- [Microsoft:分片模式与取舍](https://learn.microsoft.com/en-us/azure/architecture/patterns/sharding)
+- [PostgreSQL:备份与恢复](https://www.postgresql.org/docs/current/backup.html)
+
+---
+
+## 04 CAP 与一致性:断网时,谁还能回答
+
+比较拒绝部分修改与两侧都接受修改的结果,明确商品读写各自的承诺。
+
+### 从一个问题开始
+
+东、西两区都在编辑商品 42,突然两区互相联系不上,但各自仍能接到用户请求。
+
+**这次的终点**
+
+比较拒绝部分修改与两侧都接受修改的结果,明确商品读写各自的承诺。
+
+#### 开始前,确认你在这里
+
+- 完成第 03 课,知道副本可能暂时落后。
+- 在纸上建立两份 name=水杯、version=7 的记录;本课不进行断网或数据库故障操作。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 推演同一旧版本在两区同时被修改
+- 指出每个选择牺牲了什么
+- 把列表读取与保存修改分别定规则
+
+### 一步步推演场景
+
+#### 1. 建立两个区域的共同初始值
+
+在东、西两个框都写商品 42:name=水杯、version=7。切断两个框之间的箭头,只保留各自到用户的箭头,列出每侧无法知道的事实。
+
+**对照你的推演**
+
+两侧都能收到用户请求,却不能及时知道对方是否已改过商品;“用户能访问”不等于两区能协调。
+
+**从这里理解原因**
+
+东区和西区都保存商品 42:name 为“水杯”,version 为 7。随后区域间断开,但用户仍能访问两区。两侧都不知道另一边是否已经修改这个商品,增加本地缓存也无法补上缺失消息。
+
+#### 2. 选择由权威侧确认修改
+
+规定只有联系到权威节点的一侧能确认 version=7 的修改。东区可联系,西区不可联系;写下两位用户分别得到什么响应。
+
+**对照你的推演**
+
+东区可接受有效修改;西区必须拒绝或等待。同一旧版本不被两侧同时确认,但部分用户修改无法完成。
+
+**从这里理解原因**
+
+若只有能联系权威节点或满足协调协议的一侧可以执行“WHERE version=7”的修改,另一侧拒绝或等待,就能保护同一版本只接受一次修改的约定,但部分请求无法正常完成。多数派还需要正确协议与持久化规则,不能只数节点证明一致性。
+
+#### 3. 选择两侧继续接受修改
+
+取消上一步限制,让东区将名称改为白色水杯、西区改为透明水杯,并都确认成功。恢复两区通信后,尝试给出唯一结果。
+
+**对照你的推演**
+
+会得到两份冲突的修改;需要合并、选胜者或人工确认,不能声称曾存在跨两区唯一修改顺序。
+
+**从这里理解原因**
+
+若两区都根据本地 version=7 接受名称修改,可能分别写成“白色水杯”和“透明水杯”,并都向用户确认成功。恢复后虽然能检测冲突,却需要额外的取舍或人工确认。采用最终收敛意味着放弃本例跨区域单一修改顺序的承诺。
+
+#### 4. 把展示读取和修改确认分开
+
+为商品列表与保存价格分别选读取/写入位置。列表允许显示刷新时间,改价仍需非负整数分和有效版本。
+
+**对照你的推演**
+
+列表可以接受标明时效的副本结果;确认修改需受保护的权威边界,同一产品的操作可有不同承诺。
+
+**从这里理解原因**
+
+商品列表可读较近的副本并显示刷新时间,保存商品修改则访问能保护版本条件的权威边界。一个服务可以让不同操作采用不同一致性策略;价格变更也属于商品修改,price_cents 仍保持非负整数。
+
+#### 5. 解释正常网络下仍有的权衡
+
+重新接通两区,把每次等待远端复制增加的延迟写到时序图。此时没有断网,再列出仍要付出的时间与资源成本。
+
+**对照你的推演**
+
+正常网络下复制仍有延迟和成本;CAP 的取舍发生在特定分区条件下,不能当作平时任意选两个的开关。
+
+**从这里理解原因**
+
+网络没有分区时仍要在复制等待、延迟、吞吐和成本之间选择。CAP 是特定模型下的不可能性结论,不是“平时任意选两个”的产品配置表,也不能替代对超时、故障恢复和业务冲突的完整设计。
+
+### 检查推演结果
+
+- 能用节点仍在运行但彼此收不到消息的例子解释网络分区。
+- 能说清拒绝一次无法确认的商品修改为何保护版本规则,却牺牲了该请求的可用性。
+- 能指出最终一致性需要收敛机制,而且不自动包含固定时间上限。
+
+### 常见误区
+
+#### 面试时说 CAP 可以在 C、A、P 中随便选两项。
+
+原因:忽略了网络可能分区这一前提,也没有说明严格定义。
+
+处理:先说明发生分区,再讨论坚持线性一致性或让隔离节点继续正常回应的代价。
+
+#### 两区都确认同一商品版本修改成功,合并后一个用户的修改消失。
+
+原因:只考虑数据最终收敛,没有兑现向用户承诺的修改顺序。
+
+处理:让权威边界决定版本修改,或明确允许冲突并定义选择和通知规则。
+
+### 面试问答
+
+如何准确解释 CAP,而不只是背“最多满足两个”?
+
+先给出跨节点消息无法送达的分区场景,说明线性一致性要求已完成写入对后续操作的顺序约束,可用性要求非故障节点的每个请求都能完成正常操作。在这个前提下,隔离侧若无法获知新写,就只能牺牲一致性或部分请求可用性。工程选择应针对具体数据和操作,正常网络下还需评估延迟与复制成本。
+
+追问:
+
+- CAP 的一致性与 ACID 中的 C 为什么不是同一个定义?
+- 如果事先把不同商品的写入归属分配给不同区域,会改变什么取舍?
+
+### 自己设计一次
+
+商品服务的“商品列表”和“按预期版本保存名称”都要跨区域服务。分区时应如何分别处理?
+
+提示:先问旧读或并发修改会造成什么后果,再决定能否让每一侧自行作答。
+
+参考答案:
+
+商品列表可返回本地较旧数据并说明更新时间,通信恢复后追赶;若要求读己之写,可路由至权威侧或等待目标版本。保存名称应由能维护版本条件的权威事务或协调协议决定,无法确认的一侧暂缓。前者允许旧读,后者牺牲部分写可用性。若两侧都接受修改,则必须承认并处理冲突,不能再承诺同一版本只有一个成功修改。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 网络分区不是数据库分片
+
+回看“建立两个区域的共同初始值”的推演。网络分区表示一些节点互相收不到消息,节点本身可能仍在运行,也各自能接收用户请求。数据库分片是主动把数据拆开存放,两个词描述完全不同的问题。
+
+#### CAP 的 C、A 各有严格含义
+
+回看“选择由权威侧确认修改”的推演。这里的 C 指线性一致性:操作看起来按尊重实际先后关系的顺序作用于同一份数据。A 要求每个非故障节点收到的请求最终得到符合该操作的响应,不能靠一律报错充数。发生分区时,无法对所有请求同时保证这两点。
+
+#### 最终一致性不承诺立刻同步
+
+回看“选择两侧继续接受修改”的推演。在没有新更新、通信恢复并持续完成同步与冲突处理等前提下,副本最终收敛。它本身不给出“最多 1 秒”的界限。产品若要求几秒内可见,还需要额外的延迟目标、监测和补偿路径。
+
+#### 一致性目标需要落实到业务操作
+
+回看“把展示读取和修改确认分开”的推演。“同一商品版本只能接受一次修改”可由权威事务中的版本条件保护,防止后写静默覆盖先写;“商品列表稍后显示最新名称”可以接受短暂旧读。读己之写只保证用户能看到自己的已完成修改,比所有用户都共享实时顺序的要求更窄。
+
+### 延伸阅读
+
+- [Gilbert 与 Lynch:CAP 原始论文](https://www.cs.princeton.edu/courses/archive/spr22/cos418/papers/cap.pdf)
+- [etcd:API 保证与线性一致读取](https://etcd.io/docs/v3.5/learning/api_guarantees/)
+
+---
+
+## 05 缓存的四类难题:旧值、穿透、击穿与雪崩
+
+沿读写时间线定位旧值来源,并计算缓存失效后的数据库压力。
+
+### 从一个问题开始
+
+水杯详情查询变快了,但改价后偶尔仍显示旧价;缓存重启时数据库又突然变忙。
+
+**这次的终点**
+
+沿读写时间线定位旧值来源,并计算缓存失效后的数据库压力。
+
+#### 开始前,确认你在这里
+
+- 完成第 04 课,已能区分允许旧读的展示与必须保护版本的修改。
+- 沿用商品 42,价格用整数分,先写下 price_cents=1990、version=7。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 画出第一次查询和第二次查询的差别
+- 用交错请求复现旧价格回填
+- 计算失效后需要限制的回源量
+
+### 一步步推演场景
+
+#### 1. 观察一次缓存未命中
+
+把商品 42 的缓存清空画在纸上,数据库保留 price_cents=1990、version=7。依次推演两次读取,数出数据库访问次数。
+
+**对照你的推演**
+
+第一次读库并回填,第二次直接用缓存;两次请求通常只需一次回源,前提是缓存未过期且写入成功。
+
+**从这里理解原因**
+
+用户查询商品 42:缓存没有 → 数据库返回商品名称、price_cents=1990 和 version=7 → 应用回填并响应。第二次查询直接命中。价格统一以整数分表示;缓存与数据库使用同一字段约定。
+
+#### 2. 按触发条件选择办法
+
+分别写三张卡:大量不存在的商品编号、一个热门商品刚过期、大批商品同时过期。为每张卡选择校验/空值缓存、合并查询或错开过期。
+
+**对照你的推演**
+
+三个压力来源不同:无效编号反复查库、同一热点并发查库、许多键同时查库,措施应对应触发原因。
+
+**从这里理解原因**
+
+不存在的编号可做输入校验、短期空值缓存或布隆过滤器预筛;布隆过滤器可能误判“可能存在”,仍需查库,并要跟随新数据更新。热点失效可合并回源,成批过期可错开时间。不要用一种办法回答所有缓存问题。
+
+#### 3. 推演旧数据被重新写回
+
+按 A 读到 1990/v7→A 暂停→B 写 2490/v8 并删缓存→A 回填的顺序画时间线,最后查看库和缓存各是什么。
+
+**对照你的推演**
+
+数据库是 2490/v8,缓存却可能回到 1990/v7;更新库后删除缓存仍未消除这个交错窗口。
+
+**从这里理解原因**
+
+请求 A 读到旧价格 1990 和 version=7 后暂停;请求 B 将价格改为 2490、版本加一并删除缓存;随后 A 把旧快照回填。可见先更新库再删缓存仍有并发窗口。版本条件、可靠失效事件和有限旧值策略可组合,但要说明各自保障范围。
+
+#### 4. 比较删除、更新与双删
+
+在上一步时间线分别尝试“直接更新缓存”“立即删除”“延迟再删除”,再把 A 的暂停时间继续拉长。
+
+**对照你的推演**
+
+更新可能乱序,删除需要重新读库,延迟双删仍可遇到更晚的旧回填;没有固定等待能证明所有慢请求已结束。
+
+**从这里理解原因**
+
+直接更新缓存可能遇到并发写的先后颠倒;删除缓存让下一次读取从数据库重新获取,但会增加回源。“隔一会儿再删一次”只是缩小部分竞态窗口,无法证明所有慢请求都已经结束。强一致业务应采用能明确验证版本或读取权威数据的路径。
+
+#### 5. 计算缓存失效后的压力
+
+用每秒 1000 次查询、95% 命中率先算正常读库量;再把命中率改成 0,算倍数并写出一个保护数据库的上限。
+
+**对照你的推演**
+
+读库需求从约 50 次/秒升至 1000 次/秒,即 20 倍;回源并发与受理能力必须有边界。
+
+**从这里理解原因**
+
+假设查询量为 1,000 次/秒、命中率 95%,正常回源约 50 次/秒。缓存全部失效会把回源需求推向 1,000 次/秒,是原来的 20 倍。应先有回源并发上限、受控预热和可解释降级,再讨论提高命中率。
+
+### 检查推演结果
+
+- 能根据请求特征分别判断穿透、击穿和雪崩。
+- 能复述“旧读取在删除之后回填”的竞态时间线。
+- 能算出失效前后数据库需求的倍率,并指出数据库未必能承受。
+
+### 常见误区
+
+#### 空值永久缓存后,新建任务一直查不到。
+
+原因:不存在也是会变化的状态,空值缓存没有合适的有效期或失效处理。
+
+处理:给空值较短有效期,并在创建后处理对应失效;严格写后读走权威数据路径。
+
+#### 缓存故障时全部请求直接查库,数据库也故障。
+
+原因:把缓存当成可随时移除的优化,没有按冷缓存路径设计容量。
+
+处理:限制回源并发与等待,保留必要能力,对不可承接的请求明确拒绝或降级。
+
+### 面试问答
+
+缓存与数据库如何保持一致?
+
+先明确哪些操作要求新鲜到什么程度,再选读取和更新策略。Cache-aside 配合提交后失效很常见,但有删除失败和旧请求晚回填的窗口;可以借助版本、可靠失效和对账缩小或修复窗口。对严格授权或不可破坏的规则,应让权威事务边界作决定,不能单凭延迟双删宣称强一致。
+
+追问:
+
+- 为什么 TTL 加随机值不能处理缓存集群整体故障?
+- 布隆过滤器能否单独确认一件商品存在?
+
+### 自己设计一次
+
+商品服务 每秒有 2,000 次商品详情查询,缓存命中率 98%,数据库稳定承受 200 次/秒。缓存整体故障时,如何避免连带故障?
+
+提示:先计算正常回源量,再讨论允许什么请求等待、拒绝或返回旧值。
+
+参考答案:
+
+正常回源约 40 次/秒,整体失效时需求会增至 2,000 次/秒,达到数据库稳定能力的 10 倍。应把回源限制在有余量的预算内,按用户和业务优先级分配,减少非必要刷新并返回明确的稍后重试信息;仅对允许旧读的展示数据使用已有旧值。恢复后分批预热、合并热点回源,避免一起重建。权限和修改版本判断不能拿未知或过期缓存值当许可。更大的缓存不能消除整体故障时的这个边界。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### Cache-aside:查不到时再去资料室
+
+回看“观察一次缓存未命中”的推演。应用先读缓存,未命中再查数据库并回填;更新业务时先提交数据库,再使相关缓存失效,是常见模式。它减少重复读取,但不自动让两套存储保持强一致,也不能把缓存失败当成业务事实不存在。
+
+#### 穿透、击穿、雪崩是不同压力来源
+
+回看“按触发条件选择办法”的推演。穿透:反复查询根本不存在的 product_id,每次都落到数据库。击穿:一个极热键失效,许多人同时回源。雪崩:很多键同时过期或缓存整体不可用,回源量突然放大。名称不重要,先识别是无效请求、单个热点还是大面积失效。
+
+#### 合并回源与打散过期
+
+回看“按触发条件选择办法”的推演。同一个热点未命中时,只允许少量请求负责重建,其余等待或使用允许的旧值,可减轻击穿。让键的过期时间适度分散,可减少同步过期;但缓存集群整体故障时,TTL 抖动无法提供保护,仍要限制回源容量。
+
+#### 旧值可接受到什么程度由业务决定
+
+回看“推演旧数据被重新写回”的推演。商品列表可暂时显示旧名称和价格,但修改时的权限和预期版本必须由权威存储确认。缓存保存 id、name、description、is_active、price_cents 与版本。TTL 只是失效条件,慢回填和依赖故障仍会扩大可见延迟,不能把“30 秒过期”当作严格新鲜度保证。
+
+### 延伸阅读
+
+- [Microsoft:Cache-aside 与一致性边界](https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside)
+- [Amazon Builders’ Library:缓存挑战与策略](https://aws.amazon.com/builders-library/caching-challenges-and-strategies/)
+
+---
+
+## 06 消息队列:可靠交付、重复与顺序
+
+解释早确认和晚确认各自的失败窗口,并为重复、顺序和失败上限制定规则。
+
+### 从一个问题开始
+
+导出消息交给队列后,仍可能漏执行或执行两次;现在沿一条消息找出空隙。
+
+**这次的终点**
+
+解释早确认和晚确认各自的失败窗口,并为重复、顺序和失败上限制定规则。
+
+#### 开始前,确认你在这里
+
+- 完成第 05 课,并已在任务模块做过商品快照导出。
+- 在纸面标出任务 42 的记录、消息、执行和结果文件四个位置,不向真实队列注入消息。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 标出每次交接需要什么证据
+- 比较两种确认时机会产生什么后果
+- 限定商品修改必须保持顺序的范围
+
+### 一步步推演场景
+
+#### 1. 沿消息逐站标出负责者
+
+为导出任务 42 画 API→发布器→Broker→Worker→文件/任务状态的线路,在每次交接旁写出能查到的证据。
+
+**对照你的推演**
+
+至少区分业务记录、消息接收确认、成功领取和结果引用;任何前一段成功都不能替代下一段完成。
+
+**从这里理解原因**
+
+API 保存任务意图,发布器交给 Broker,Worker 取得消息后读取固定商品快照并写出清单,最后写入结果并确认消费。为每段标出证据:业务记录、发布确认、领取记录、结果引用。没有下一段证据时,不能用上一段的成功替代。
+
+#### 2. 推演先确认的遗漏窗口
+
+让 Worker 取到任务后马上确认消息,然后在写清单文件之前崩溃。沿线路标记谁还记得这项工作未完成。
+
+**对照你的推演**
+
+Broker 可能已删除待处理责任;若业务没有独立恢复记录,任务可能遗漏而不是自动重投。
+
+**从这里理解原因**
+
+Worker 收到消息后立即确认,随后在导出商品清单之前崩溃。代理已认为交接结束,不再负责重投,这次任务可能永远未执行。提前确认可以减少某些重复,但把丢失风险交给了业务恢复机制。
+
+#### 3. 推演后确认的重复窗口
+
+改为先写好文件再确认,在确认传输中切断连接;让 Broker 重投给另一名 Worker。
+
+**对照你的推演**
+
+第二名 Worker 可能再次执行任务 42;晚确认缩小遗漏窗口,却需要按业务编号保护重复效果。
+
+**从这里理解原因**
+
+Worker 已把商品清单保存成功,消费确认却因连接中断没有到达。代理重投后,另一个 Worker 再次看到任务 42。因此把确认放在成功之后仍需业务去重,不能仅凭“晚确认”推导出执行一次。
+
+#### 4. 给需要有序的对象选择顺序范围
+
+对商品 42 画第 7、8 次修改,再加入商品 99 的一次修改。比较“全部串行”和“同商品串行”,并区分最新快照与逐条动作。
+
+**对照你的推演**
+
+按商品串行能保留局部顺序且不阻塞无关商品;版本过滤只适合最新快照,不能代替必须逐条执行的动作。
+
+**从这里理解原因**
+
+同一商品的第 7、8 次修改若要求下游逐条处理,可按 product_id 路由并串行执行,第 7 次完成后再处理第 8 次。失败消息需要明确的修复或跳过规则,不同商品可并行。若下游只维护最新商品快照,可以用版本条件拒绝旧快照;这不能代替必须逐条完成的动作。严格全局排序会让一个失败阻塞更多无关工作。
+
+#### 5. 说明失败消息何时停止重投
+
+准备“文件服务短暂不可达”和“消息字段永远无法解析”两张故障卡,为它们写重试次数上限与达到上限后的去向。
+
+**对照你的推演**
+
+临时错误可有界重试;持续失败进入可查询隔离状态,恢复仍需保留业务编号并检查重复和顺序。
+
+**从这里理解原因**
+
+临时依赖错误可在预算内重试,无法解析或持续失败的消息应进入可查询的隔离状态或死信队列。死信表示等待诊断或决定,不是问题自动解决;恢复处理仍要保留原业务标识,并考虑顺序和重复效果。
+
+### 检查推演结果
+
+- 能说清 Broker 确认收到了消息,为什么仍不能显示商品清单生成成功。
+- 能分别画出先确认导致遗漏、后确认导致重复的时间线。
+- 能把“有序”限定到具体对象、分区及处理环节,而不是全局口号。
+
+### 常见误区
+
+#### 同一条消息重复写入清单文件或发送完成通知。
+
+原因:把消息只投递一次当成可靠前提,副作用目标没有去重。
+
+处理:用稳定的业务操作标识保护副作用;若目标不支持幂等或查询,超时后保留未知状态,不能盲重试。
+
+#### 任务状态从 succeeded 被重投的旧消息改回 running。
+
+原因:消费者只照消息写状态,没有验证当前业务版本和合法转换。
+
+处理:把消息当作唤醒或事实输入,让持久状态与版本规则决定是否仍可执行。
+
+### 面试问答
+
+怎样保证消息不丢、不重复?
+
+应拆成两个问题。交付侧讨论持久化、复制、发布确认、消费确认、失败重投和保留期限;业务侧用稳定标识、事务或目标端幂等来处理重复效果。先确认有遗漏窗口,后确认有重复窗口。所谓恰好一次必须指出覆盖哪些输入、存储和输出,外部系统通常需要额外配合。
+
+追问:
+
+- 为什么消费者只开一个仍不能保证永远没有重复?
+- 严格保序时,毒消息应阻塞整个系统还是某个业务键?
+
+### 自己设计一次
+
+同一导出任务有“开始导出”和“取消任务”两条消息,开始先投递却执行更慢。如何避免取消后被迟到结果重新标成成功?
+
+提示:不要只要求换一个“有序队列”;还要考虑已在执行中的外部调用。
+
+参考答案:
+
+取消应成为持久的业务请求或状态,完成结果发布时原子检查任务是否仍允许成功以及执行版本是否有效;旧执行者不满足条件就不能改写终态。同一任务可按键路由减少乱序,但不能让已经发出的外部调用瞬间消失,因此界面区分“取消申请”和“已取消”。结果文件可先单独保存,最终发布引用受状态规则约束;未采用的临时结果另作清理。代价是额外状态与查询,收益是顺序假设失效时仍保护业务结果。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 两次交接,两种确认
+
+回看“沿消息逐站标出负责者”的推演。生产者将“导出商品清单 42”交给消息代理,发布确认描述代理已按相应配置接受它;消费者处理后再确认,描述该次投递可以结清。发布确认不表示商品清单完成,消费确认何时发送则直接影响未完成工作会不会被遗漏。
+
+#### 至少一次通常意味着必须面对重复
+
+回看“推演后确认的重复窗口”的推演。在持久化、重试和可恢复等前提成立时,至少一次交付通过重新投递降低遗漏风险,消费者可能见到同一业务请求多次。至多一次倾向于不重复交付,但可能丢失。具体保证还依赖保留时间、复制、确认和故障模型。
+
+#### 交付顺序不等于副作用完成顺序
+
+回看“给需要有序的对象选择顺序范围”的推演。即使消息 1 比消息 2 先进入队列,两个消费者也可能让消息 2 先做完。许多日志系统只保证同一分区中的顺序,没有跨分区的全局顺序;重试、重投和并行执行还会影响处理顺序。
+
+#### Exactly-once 必须说明边界
+
+回看“推演后确认的重复窗口”的推演。Kafka 的事务能力可以在受支持的读、处理、写链路中把输入进度与输出原子提交;这不自动覆盖外部文件存储、通知接口或另一个数据库。外部效果需要目标系统配合事务、幂等或其他协调,不能把代理的保证直接扩展成所有业务效果恰好一次。
+
+### 延伸阅读
+
+- [RabbitMQ:可靠性与确认机制](https://www.rabbitmq.com/docs/reliability)
+- [RabbitMQ:队列顺序与重投](https://www.rabbitmq.com/docs/queues)
+- [Apache Kafka:交付语义与外部系统边界](https://kafka.apache.org/38/design/design/)
+
+---
+
+## 07 跨系统事务:本地事务、Outbox 与 Saga
+
+用同库待发送记录封住受理丢失窗口,并解释跨系统步骤失败后的补偿边界。
+
+### 从一个问题开始
+
+数据库写好了任务,队列却没收到消息;把发送顺序换一下能解决吗?
+
+**这次的终点**
+
+用同库待发送记录封住受理丢失窗口,并解释跨系统步骤失败后的补偿边界。
+
+#### 开始前,确认你在这里
+
+- 完成第 06 课,能说明消息被确认与商品清单已完成是两件事。
+- 保留任务 42 的四个位置图,增加一个“待发送记录”框,逐步推演,不改实际库。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 推演交换两次写入顺序仍会失败的原因
+- 让任务和发送意图同时存在或同时不存在
+- 区分重试、撤销入口与恢复剩余步骤
+
+### 一步步推演场景
+
+#### 1. 推演数据库与消息的双写
+
+依次推演“先写任务、后发消息”和“先发消息、后写任务”,都在两步之间插入崩溃,记录数据库与队列分别剩什么。
+
+**对照你的推演**
+
+第一种留下无人派发的任务;第二种留下查不到任务的消息。交换顺序没有形成跨系统原子提交。
+
+**从这里理解原因**
+
+先存任务再发消息:中间崩溃会留下没人执行的任务。先发消息再存任务:Worker 可能先收到消息却查不到任务,或者最终数据库写入失败。简单交换顺序无法让两个独立系统自动原子提交。
+
+#### 2. 让业务变化和发送意图一起落账
+
+现在把“任务 42”和“需要发送任务 42”的记录写进同一个数据库事务。分别在事务提交前失败与提交后 Broker 暂停时检查两条记录。
+
+**对照你的推演**
+
+提交前两条都不成立;提交后两条一起保留,Broker 暂停也不会丢掉需要派发的事实。
+
+**从这里理解原因**
+
+创建任务 42 的同一个数据库事务里写入一条“需要派发任务 42”的 Outbox 记录。事务失败时两条都没有;事务成功时即使 Broker 暂停,待发送意图仍然存在。可靠性来自持久意图与持续恢复,不是内存回调。
+
+#### 3. 承认发布器也会遇到未知结果
+
+让发布器发送成功但来不及标记已发送,或发出后收不到确认。为下一轮扫描写出它知道什么、仍不知道什么。
+
+**对照你的推演**
+
+发布器可能只能重试,因此同一事件可能到达多次;已发送也只证明派发阶段,不能表示清单已经完成。
+
+**从这里理解原因**
+
+发布器发出消息但没有收到确认,不知道 Broker 是否已经保存;即使收到确认,也可能来不及标记已发。它通常需要重试,因此消费者应识别相同业务事件。Outbox 的“已发送”状态也不等于任务已经执行完成。
+
+#### 4. 用下载入口反例理解补偿
+
+故意采用一个较差顺序:登记任务→开放下载入口→写文件。让最后一步失败,写下用户会遇到什么,以及要撤销哪项已发生动作。
+
+**对照你的推演**
+
+用户可能看到不能下载的入口;需要撤销入口并更新状态,但外部已经看见过的中间状态不能被抹去。
+
+**从这里理解原因**
+
+假设按“登记导出任务 → 发布下载入口 → 保存清单文件”推进。文件发布失败后,撤销已开放的下载入口,再将任务标记为失败。逐步记录成功证据、重复处理方式和恢复状态。这个顺序故意暴露中间状态,并非实战推荐顺序。
+
+#### 5. 选择补偿和重试的边界
+
+继续让“撤销入口”超时,再假设完成通知已经送达。分别列出可重试动作、先核实的结果与无法真正撤回的效果。
+
+**对照你的推演**
+
+撤销意图需要保留;未知文件结果先按编号核实;已读通知只能更正。实际流程宜先有文件,再发布引用。
+
+**从这里理解原因**
+
+撤销入口也可能遇到超时,需保存撤销意图并在预算内重试;文件是否写入未知时先按任务编号核实,避免误删已经发布的文件。进度通知已送达无法撤回,可发送准确的新状态。实际实现优先文件就绪后发布引用,用持久状态恢复剩余步骤。
+
+#### 6. 比较方案而不是评选万能模式
+
+分别把“同库两条记录”“多参与者统一提交”“保存派发意图”“跨服务步骤补偿”分成四张需求卡,给每张匹配一种方案并写出成本。
+
+**对照你的推演**
+
+可分别匹配本地事务、2PC、Outbox、Saga;它们解决的边界不同,可以组合,没有一个自动包办全部正确性。
+
+**从这里理解原因**
+
+单库事务更简单,适合能放在同一数据边界内的规则;2PC 需要参与者支持并承担协调成本;Outbox 处理业务状态与发送意图的原子记录;Saga 管理跨服务工作流及补偿。它们可以配合使用,但各自没有覆盖全部业务正确性。
+
+### 检查推演结果
+
+- 能指出 Outbox 原子提交的两份记录都在哪里。
+- 能解释发布器重复发送窗口,以及为什么消费者仍需幂等。
+- 能给出一个可补偿动作和一个不能真正撤销的外部副作用。
+
+### 常见误区
+
+#### 有了 Outbox 就删除全部任务恢复和积压观测。
+
+原因:把持久发送意图误解成自动完成承诺。
+
+处理:分别观察待发布、已派发未领取、执行中和终态,明确发布器与恢复流程的持续运行责任。
+
+#### 任何失败都逆序回滚,最终预算和外部账单对不上。
+
+原因:把 Saga 补偿当作数据库回滚,没有区分已产生费用和未消耗预留。
+
+处理:按业务事实定义补偿;未知的外部结果先核实,不可逆效果记录为实际发生。
+
+### 面试问答
+
+Outbox 和 Saga 分别解决什么问题?
+
+Outbox 让本地业务变化与待发事件在同一事务里记录,避免提交后发送意图凭空丢失,仍可能重复发布。Saga 用多个本地事务推进跨服务业务,在失败时选择继续或做补偿,通常没有整体事务隔离。二者可组合:Saga 的某一步可通过 Outbox 可靠记录下一步通知。
+
+追问:
+
+- 2PC 的参与者需要具备什么能力?
+- 为什么已经送达的导出完成通知不能像数据库事务一样回滚?
+
+### 自己设计一次
+
+教学反例先发布了商品清单下载入口,随后文件保存失败。应如何补偿,以及怎样简化实际流程?
+
+提示:把任务状态、入口可见性和文件存在性看成可分别核实的事实。
+
+参考答案:
+
+先持久记录撤销入口与失败状态的处理意图,撤销已开放入口并将任务标成失败;补偿失败仍需重试和查询,已送达的通知不会自动撤回。实际商品服务先生成文件,确认文件可读后再用受版本约束的事务发布结果引用;进度通知失败可单独补发。Outbox 记录后续派发意图,Saga 用于解释不得不跨多个本地事务时的补偿边界,两者不能让文件服务自动加入数据库事务。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 本地事务只保护同一个事务边界
+
+回看“推演数据库与消息的双写”的推演。在同一个数据库事务中,可以让商品导出任务和待派发记录一起提交或一起回滚。数据库提交后再调用 Broker 或文件服务,不会自动进入这个事务。程序里的连续两行操作不等于跨系统原子提交。
+
+#### Outbox:把待发送信件先记在同一本账上
+
+回看“让业务变化和发送意图一起落账”的推演。在同一事务中保存业务变化和待发送事件;独立发布器反复检查并发送。这样业务提交后,发送意图仍留在数据库里。但发布后标记之前崩溃会重复发送,所以它没有消除消费幂等、派发监测、保留和恢复的工作。
+
+#### 2PC:先询问能否提交,再统一决定
+
+回看“比较方案而不是评选万能模式”的推演。两阶段提交由协调者让支持协议的参与者先准备,再提交或中止。准备阶段可能占用资源;故障与协调信息不可达可能造成等待。它适用于参与者都支持相关事务协议的边界,普通文件服务 HTTP API 不能仅靠应用约定被纳入。
+
+#### Saga:一串本地完成的步骤与业务补偿
+
+回看“用下载入口反例理解补偿”的推演。为解释补偿,假设把登记导出任务、发布下载入口和保存清单文件故意拆成跨服务步骤,且错误地先开放下载、后写文件。文件发布失败时,需撤销入口并标记任务失败。补偿是新的业务操作;已经收到的进度通知不能像数据库事务那样撤回。
+
+#### 中间状态对其他请求仍然可见
+
+回看“选择补偿和重试的边界”的推演。Saga 各步分别提交,通常没有单库事务的整体隔离,其他请求可能看见下载入口却取不到文件。本例用于识别这种设计代价;实际简单商品服务优先先保存文件,确认就绪后再原子发布结果引用,不要求为讲 Saga 拆分业务。
+
+### 延伸阅读
+
+- [AWS:Transactional outbox 模式](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html)
+- [AWS:Saga 模式](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/saga-patterns.html)
+- [Microsoft:Saga 事务与补偿](https://learn.microsoft.com/en-us/azure/architecture/patterns/saga)
+- [PostgreSQL:预备事务与两阶段提交](https://www.postgresql.org/docs/current/sql-prepare-transaction.html)
+
+---
+
+## 08 幂等与未知结果:超时以后要不要再来一次
+
+为同一意图的重试找回同一任务,并保留外部结果未知时的核实路径。
+
+### 从一个问题开始
+
+点击导出后浏览器超时了,用户再点一次:应该返回原任务,还是新建任务?
+
+**这次的终点**
+
+为同一意图的重试找回同一任务,并保留外部结果未知时的核实路径。
+
+#### 开始前,确认你在这里
+
+- 完成第 07 课,已有任务记录与待发送记录的方案。
+- 准备“首次点击、立即重试、次日再次导出”三张请求卡,商品编号列表保持相同。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 区分内容相同与意图相同
+- 挡住两台 API 同时创建同一任务
+- 约定超时后查询什么、记录保留多久
+
+### 一步步推演场景
+
+#### 1. 比较三次外观相同的提交
+
+将首次导出、超时后的同次重试、次日主动新导出排成三张卡。商品列表相同,为它们分配能表达用户意图的编号。
+
+**对照你的推演**
+
+前两张使用同一个意图编号,第三张通常用新编号;仅看请求正文相同会误吞新的需求。
+
+**从这里理解原因**
+
+用户第一次点击导出,网络超时后重试,以及次日主动再导出一次,商品编号列表可能完全一致。前两次应共享一个意图编号,第三次通常是新意图。仅对请求正文做哈希去重,会误把用户的第二次真实需求吞掉。
+
+#### 2. 定义查询得到的几种状态
+
+为同一意图画处理中、已完成、明确失败、待确认四个状态,分别写再次收到请求时返回的任务编号或核实动作。
+
+**对照你的推演**
+
+处理中返回原任务,完成返回原结果;明确失败与未知结果不能一律当作“重新执行即可”。
+
+**从这里理解原因**
+
+服务可记录处理中、已完成、明确失败或待确认。相同意图仍在处理中时,返回相同任务编号让用户查询;已完成时返回与原请求语义一致的结果。明确失败能否重试,要看该失败是否证明没有留下副作用。
+
+#### 3. 推演两个副本同时受理
+
+让 API-A、API-B 同时读到幂等记录不存在,随后都尝试创建。圈出必须由数据库一次决定胜者的位置。
+
+**对照你的推演**
+
+仅先查后写会创建两次;唯一约束与事务使一方创建,另一方读取同一已有任务。
+
+**从这里理解原因**
+
+API-A 和 API-B 同时检查同一个键,都看到“不存在”,随后都创建任务。先查再写没有挡住竞争;需要由权威存储的唯一性与事务等原子机制决定谁创建成功,另一方读取已建立的同一任务。
+
+#### 4. 把保护延伸到外部调用
+
+让文件服务已经写入但响应丢失,本地任务尚未记成功。尝试凭稳定 job_id 与执行版本查询文件,再判断是否重写。
+
+**对照你的推演**
+
+可按对象标识核实时先找回远端结果;没有幂等与查询能力时,本地去重无法排除重复远端写入。
+
+**从这里理解原因**
+
+Worker 写入共享文件服务时,使用稳定的任务编号与本次执行版本作为对象标识;服务支持幂等请求或按标识查询时,超时后先核实。如果两种能力都没有,本地锁与数据库去重无法排除“远端已写、本地未记”的窗口。
+
+#### 5. 说明有效期与异常处理
+
+接口承诺 7 天内可重试,记录却在 1 小时后删除。安排第 2 天一次重试,再写出记录保留和超过期限后的处理规则。
+
+**对照你的推演**
+
+旧重试可能变成新执行,违反原承诺;记录期限必须覆盖承诺,并为持续未知或过期请求提供明确核实路径。
+
+**从这里理解原因**
+
+假设接口允许用户 7 天内重试,去重记录若 1 小时就删除,会破坏这个承诺。超过约定期限或外部结果持续未知时,需要明确的业务查询、人工确认或对账路径,不能假装永久幂等或强行显示成功。
+
+### 检查推演结果
+
+- 能区分输入相同与业务意图相同。
+- 能说明超时至少可能对应未执行、执行中、已执行三种情况。
+- 能指出本地唯一约束保护不了不配合的外部文件写入。
+
+### 常见误区
+
+#### 为了重试每次生成一个新的 request_id,出现多份商品清单。
+
+原因:把技术请求编号当成业务幂等键,每次重试都变成新意图。
+
+处理:一次业务意图沿用同一个幂等键,技术 trace_id 可以另外记录。
+
+#### 记录显示 failed,就立刻重新向文件服务写入。
+
+原因:把响应超时归为确定失败,没有保留未知结果。
+
+处理:按操作编号查询或使用目标端幂等重试;不能确认的非幂等操作先进入待确认状态。
+
+### 面试问答
+
+如何设计一个幂等接口?
+
+先定义“同一次业务操作”的键及用户作用域,再验证同键请求内容一致,用权威存储的原子机制协调并发,把去重状态与本地业务效果一起提交。重复请求得到可查询且语义一致的结果。最后讨论保留期限、处理中状态、超时未知结果和外部副作用的保护边界。
+
+追问:
+
+- 为什么不能只用请求正文的哈希作为幂等键?
+- 去重记录与外部效果不能在同一事务里时怎么办?
+
+### 自己设计一次
+
+用户提交商品清单导出时返回超时,前端准备换一个 UUID 再提交。后端任务实际上已创建,应该怎样修改交互和语义?
+
+提示:同一次操作的业务编号要稳定,界面还需要知道自己只是暂时拿不到结果。
+
+参考答案:
+
+前端在首次提交前生成并保存本次意图的幂等键,后续重试继续使用该键;后端将用户、操作、键和请求摘要关联,在原子事务中创建一次任务并记录结果。重复提交返回同一任务编号,参数不一致时拒绝冲突,页面显示处理中并查询该任务。技术请求仍可以各有 trace_id 便于排查。服务要明确键保存多久;若后续外部文件服务不支持幂等或查询,任务创建幂等并不能消除远端未知结果,还需独立处理。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 幂等关心重复请求的业务效果
+
+回看“比较三次外观相同的提交”的推演。重复查询商品通常不会产生新副作用;“将名称设为水杯”和“再创建一件水杯商品”语义不同。同一请求可被多次接收、检查甚至计算,只要受保护的业务效果不额外发生,就可能满足约定的幂等语义。
+
+#### 幂等键代表同一个意图
+
+回看“比较三次外观相同的提交”的推演。客户端为一次“导出商品清单”意图提供稳定编号,超时重试沿用它。两次有意导出同一批商品应使用不同编号。键还要和用户、操作类型及请求内容关联:同键不同参数不能静默当成原请求成功。
+
+#### 去重记录要和所保护的效果一起考虑
+
+回看“推演两个副本同时受理”的推演。同一数据库中的唯一键和事务可以原子地创建一次任务并记下去重结果;先查缓存、再执行、最后标记成功会留下并发和崩溃窗口。若效果发生在外部文件服务,数据库去重记录无法独自保证远端只执行一次。
+
+#### 未知不是失败
+
+回看“把保护延伸到外部调用”的推演。超时只说明调用方在期限内没有获得确定答复。请求可能未到达、正在执行,也可能已完成而响应丢失。结果未知时盲目重试非幂等操作,可能重复创建导出任务、写入文件或发送通知。
+
+#### 幂等也有时间和身份边界
+
+回看“说明有效期与异常处理”的推演。去重记录过期后,迟到重试可能被视为新请求;应依据业务允许的重试与查询周期定义保留期限。请求编号只用于识别意图,不能替代鉴权,用户也不能凭猜到别人的键读取结果。
+
+### 延伸阅读
+
+- [Amazon Builders’ Library:安全重试与幂等 API](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/)
+- [RFC 9110:HTTP 幂等方法语义](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2)
+- [PostgreSQL:唯一约束](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS)
+
+---
+
+## 09 分布式锁与租约:过期后旧 Worker 为什么还在写
+
+用过期与恢复的反例检查接管协议,说明写入端如何拒绝过时结果。
+
+### 从一个问题开始
+
+旧 Worker 卡住后,新 Worker 接管了导出;旧 Worker 又恢复并试图写结果。
+
+**这次的终点**
+
+用过期与恢复的反例检查接管协议,说明写入端如何拒绝过时结果。
+
+#### 开始前,确认你在这里
+
+- 完成第 08 课,理解同一任务重复执行需要保护最终效果。
+- 在纸上画从 0 秒到 60 秒的轴,准备 A、B 两名执行者和一个结果发布位置。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 按秒画出两个执行者重叠运行的窗口
+- 避免旧执行者删除新执行者的资格
+- 解释序号保护能保证和不能保证的边界
+
+### 一步步推演场景
+
+#### 1. 推演租约过期的时间线
+
+在时间轴标记 A 获得 30 秒资格、10 秒暂停、31 秒 B 接管、50 秒 A 恢复,圈出两段程序都能执行的时段。
+
+**对照你的推演**
+
+第 50 秒以后 A、B 都可能运行;租约到期没有停止 A 的操作系统进程。
+
+**从这里理解原因**
+
+A 取得 30 秒租约,执行到第 10 秒暂停了 40 秒;第 31 秒 B 取得新的租约并开始工作;第 50 秒 A 恢复。现在 A 和 B 的代码都可能在运行。这个反例说明“租约同时只给一个人”与“世界上只有一个执行中的进程”不同。
+
+#### 2. 保护释放与续租
+
+在第 50 秒让 A 删除锁,再问当前锁究竟属于谁;同时标记 A 暂停前已发出的文件写请求。
+
+**对照你的推演**
+
+不核对所有者会删掉 B 的锁;A 停止新工作也不能收回远端已收到的写入。
+
+**从这里理解原因**
+
+A 恢复后尝试释放锁,应先原子核对所有者;若当前锁属于 B,就不能删除。A 续租失败后应停止开始新工作,但已经发出去的远端请求可能仍在执行,协作停止不能收回网络中已交付的副作用。
+
+#### 3. 让写入端检查新旧序号
+
+给 A 序号 41、B 序号 42,让结果存储先接受 42,再收到 41。把检查序号和真正写数据画在同一个提交框里。
+
+**对照你的推演**
+
+存储记录 42 后应拒绝 41;若检查与写入分离,两个请求仍可能插队破坏保护。
+
+**从这里理解原因**
+
+设 A 的 token 为 41,B 为 42。资源先接受 B 的 42 并记录它,随后拒绝 A 的 41。校验 token、更新最大序号与写入数据必须处于同一个可靠的原子边界,否则检查后仍可能被并发插队。
+
+#### 4. 补充 fencing 的精确边界
+
+交换上一步的到达顺序:资源还没见过 42 就收到过期 A 的 41。只按“拒绝小于已见最大序号”规则判断它会不会被接受。
+
+**对照你的推演**
+
+41 仍可能被接受;这种 fencing 保护新写不被旧序号覆盖,不自动保证租约一过期就拒绝所有旧写。
+
+**从这里理解原因**
+
+“只拒绝小于已见最大值”的资源,在还未见过 42 时可能接受 41;它保证新序号已被资源接受后的旧写不能覆盖,不自动证明“租约一过期就禁止任何旧写”。若需要后者,应由权威状态原子验证当前有效持有版本或采用满足该要求的协调协议。
+
+#### 5. 先问能不能不用长时间持锁
+
+若只要求任务终态最多发布一次,尝试允许两名 Worker 都计算,再由数据库按状态和版本决定谁能发布;另圈出文件写入保护。
+
+**对照你的推演**
+
+可以把长计算放到锁外,用短的条件提交保护终态;重复计算成本与外部副作用仍需分别约束。
+
+**从这里理解原因**
+
+如果目标只是同一个任务只能发布一个终态,可让数据库根据当前版本和合法状态决定发布是否成功,计算阶段允许出现重复,再限制成本。短事务、唯一约束或按键串行有时比跨多次网络调用持有长锁更清楚,但不能省略外部副作用的单独保护。
+
+### 检查推演结果
+
+- 能说明租约键过期为什么不能终止旧 Worker。
+- 能区分随机所有者 token 和单调递增 fencing token。
+- 能指出真正拦截旧写的是资源端的原子校验,而不是客户端自己检查一次。
+
+### 常见误区
+
+#### 把锁时间延长到一天,就认为重复执行已被解决。
+
+原因:只延后接替时间,没有处理进程暂停、网络延迟或失效锁。
+
+处理:定义租约接管、续期失效和资源写入约束,按业务选择版本检查与幂等。
+
+#### 日志里有 fencing token,旧结果仍然覆盖了新结果。
+
+原因:目标存储并未在写入时校验并拒绝旧 token,或检查与写入不原子。
+
+处理:让被保护的资源执行原子新旧判断;不支持此机制的外部系统需其他可证明的保护。
+
+### 面试问答
+
+分布式锁设置过期时间后,为什么仍可能发生并发写?
+
+锁服务回收的是资格,旧客户端可能暂停后恢复,并不知道自己的资格已过期;续租也存在延迟和失败。释放要原子核对所有者,受保护写入要有资源端版本或 fencing 约束。还需说明序号怎样可靠生成、资源何时接受它,以及外部系统能否配合。
+
+追问:
+
+- 为什么随机 UUID 不能直接用来判断哪个持有者更新?
+- fencing 能否阻止已发出的文件写入或进度通知?
+
+### 自己设计一次
+
+A 的 token=17,租约过期后 B 获得 token=18 并成功发布商品清单。A 随后恢复,试图发布旧商品清单并删除锁。应分别怎样处理?
+
+提示:结果写入和锁释放是两个资源上的动作,需要各自的判断。
+
+参考答案:
+
+结果存储已接受 18,A 的 17 应在原子写入检查处被拒绝,不能覆盖 result_ref 或终态;锁释放也必须核对当前所有者,不能删除 B 的锁。A 的临时输出可被标为未采用并另行清理。若 A 之前已写出文件,这个外部效果不会因结果发布被拒绝而自动消失;可按执行编号识别未采用文件,再安排受控清理。若资源尚未见到 18,仅比较最大已见 token 的 fencing 不能单独证明 A 的任何写都必然被拒绝,应明确是否还需当前租约版本检查。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 租约是有期限的使用资格
+
+回看“推演租约过期的时间线”的推演。锁服务可以给 Worker 一段时间的独占资格,期限结束后允许其他 Worker 接替,避免崩溃者永远占住资源。但旧 Worker 可能只是暂停或断网,它的程序并不会因为远端键过期就自动消失。
+
+#### 所有者标识避免删掉别人的锁
+
+回看“保护释放与续租”的推演。释放和续期都要确认仍属于自己,检查与修改还需原子完成。随机所有者标识主要用来区分哪次持有,它不表达新旧先后,不能直接代替 fencing token。
+
+#### Fencing:资源端识别过时的使用者
+
+回看“让写入端检查新旧序号”的推演。一种常见设计是在每次接管时得到单调递增的序号。受保护资源原子地记录已接受的最大序号,并拒绝更小序号的写入;这样接受新执行者后,旧执行者的迟到写不能覆盖它。只有把序号传进日志,却不在写入处校验,没有保护作用。
+
+#### 锁、去重和事务解决的不是同一个问题
+
+回看“先问能不能不用长时间持锁”的推演。锁协调谁可以尝试工作;幂等限制重复请求的额外效果;事务保护一个数据边界内的原子变化。任务终态发布可用持久状态和版本条件保护;外部文件服务若不识别 token 或幂等键,拿了本地锁仍不能保证只产生一份外部文件。
+
+### 延伸阅读
+
+- [Redis:分布式锁、所有者与时限假设](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)
+- [Google Research:Chubby 锁服务论文](https://research.google/pubs/the-chubby-lock-service-for-loosely-coupled-distributed-systems/)
+
+---
+
+## 10 超时、重试、熔断与隔离:阻止局部故障扩散
+
+计算等待与重试如何放大故障,并用独立预算保住商品查询。
+
+### 从一个问题开始
+
+文件存储变慢后,商品查询也开始超时;CPU 并不忙,服务为什么仍被拖住?
+
+**这次的终点**
+
+计算等待与重试如何放大故障,并用独立预算保住商品查询。
+
+#### 开始前,确认你在这里
+
+- 完成第 09 课,已区分计算、远端写入和终态发布。
+- 画出 API → 导出 → 文件服务三段调用,给一次交互请求写下 6 秒总预算。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 核算剩余时间还能支持几次尝试
+- 算出多层重试的最大调用数
+- 为导出故障设计准确降级和渐进恢复
+
+### 一步步推演场景
+
+#### 1. 追踪慢调用占住了什么
+
+让文件写入从 1 秒变成 20 秒,到达速度不变。为每个未完成请求放一个筹码,标出它占用的连接、内存和执行名额。
+
+**对照你的推演**
+
+同时停留的工作会增多,连接与并发槽可先耗尽;CPU 低仍可能排队和超时。
+
+**从这里理解原因**
+
+商品服务 写入文件存储时,即使 CPU 很空,也可能占着连接、协程、内存和并发槽。延迟从 1 秒升到 20 秒,同样的到达速率会让更多工作同时停留在系统内,其他请求开始排队,最终形成超时。
+
+#### 2. 给一项请求安排总预算
+
+给交互操作 6 秒预算,划掉已经读取商品的 2 秒,再尝试放入三次 3 秒的写入及等待。写出何时不应发起下一次。
+
+**对照你的推演**
+
+只剩 4 秒,放不下三次 3 秒;连接、退避与池等待也占预算,耗时较长的工作应有可查询的异步状态。
+
+**从这里理解原因**
+
+假设一个交互操作总预算为 6 秒,前置商品读取已用 2 秒,就不能再给文件服务三次各 3 秒的尝试。预算必须包括连接、等待、尝试和退避,并根据剩余时间决定是否值得发起下一次。长任务可转为明确可查询的异步流程。
+
+#### 3. 算出多层重试的放大
+
+画 API 最多尝试 3 次,每次导出服务又最多写文件 3 次的树,数叶子节点。
+
+**对照你的推演**
+
+最坏可出现 9 次文件请求;逐层重试相乘,故障时可能把下游压得更慢。
+
+**从这里理解原因**
+
+API 调用导出服务最多尝试 3 次,导出服务每次又调用文件服务最多 3 次,一次用户操作最坏可触发 9 次文件服务请求。再加一层会继续乘。约定由合适的一层主导重试,并让其他层理解重试预算。
+
+#### 4. 区分值得重试的错误
+
+把参数错误、无权限、短暂网络中断、限流、文件写结果未知分成五张卡,为每张选择立即返回、稍后尝试或先查状态。
+
+**对照你的推演**
+
+参数和权限问题原样重试无益;临时错误要看预算,限流尊重等待,未知写入先用幂等或查询核实。
+
+**从这里理解原因**
+
+短暂网络故障可在操作可重试且仍有预算时尝试;参数无效或权限不足,原样再发通常没有意义。限流响应应尊重服务建议的等待时间。文件写入若结果未知,先使用幂等语义或状态查询,不能把所有超时都套进自动重试。
+
+#### 5. 让熔断和隔离一起保护其余能力
+
+文件服务继续失败,给导出和商品查询分别划执行名额,再设计暂停调用、少量探测、恢复放量三个阶段。
+
+**对照你的推演**
+
+导出可明确暂不可用,商品查询保留资源;探测恢复后逐步放量,积压不能一次冲回依赖。
+
+**从这里理解原因**
+
+文件服务 A 连续失败后,熔断器暂缓新调用并少量探测;即使探测仍慢,A 的独立并发上限也防止它占满所有资源。历史查询走自己的预算,界面准确显示导出暂不可用。恢复后渐进放量,避免全部积压同时冲回。
+
+### 检查推演结果
+
+- 能把一次请求的排队、尝试和退避都纳入总时间预算。
+- 能算出两层各 3 次尝试的最坏下游请求数为 9。
+- 能说明熔断减少故障调用,隔离保留其他能力所需资源。
+
+### 常见误区
+
+#### 所有服务都很积极地重试,故障后流量翻了数倍。
+
+原因:每层独立重试,既没有总预算,也没有区分错误性质。
+
+处理:明确主导重试层,采用有上限的退避与抖动,并限制重试占比。
+
+#### 熔断打开后页面仍然卡住。
+
+原因:熔断只限制新调用,已有请求或共用资源仍然耗尽。
+
+处理:结合单次超时、总截止时间和资源隔离,并定义快速返回的业务结果。
+
+### 面试问答
+
+重试、熔断、限流和隔离有什么区别?
+
+重试提高一次可安全重做操作的成功机会;熔断根据依赖状态减少对故障目标的新调用;限流控制进入或发出的速率;隔离限制某类工作占用的并发或资源。它们从不同方向减少故障扩散,需要统一的时间、容量和业务结果约束,不能相互替代。
+
+追问:
+
+- 为什么给每层相同超时容易超过用户总预算?
+- 熔断半开时为什么要限制探测数量?
+
+### 自己设计一次
+
+一个 商品服务 请求总预算 8 秒,预处理已耗时 2 秒。文件服务一次尝试上限 3 秒,两次尝试之间要退避 1 秒,还需 1 秒返回余量。剩余预算够两次完整尝试吗?
+
+提示:把所有阶段相加,不要只数真正写入文件的时间。
+
+参考答案:
+
+剩余 6 秒,但两次完整尝试加退避与返回余量需要 3+1+3+1=8 秒,因此不够。可只安排一次完整尝试并保留余量,或根据真实延迟和剩余时间缩短后续尝试;不能为了凑次数盲目设置过短超时导致更多失败。若操作可异步,可返回已受理和任务查询,但受理必须符合持久化承诺。文件服务的未知结果还需幂等或核实,剩余时间充足也不是盲目重试的理由。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 超时限制等待,截止时间限制整个过程
+
+回看“给一项请求安排总预算”的推演。连接、响应读取、排队和整项任务可以有不同预算。单次调用设置 5 秒,不代表三次重试加等待仍会在 5 秒内结束。上游剩余预算应约束下游尝试,并为返回和清理留时间;超时本身不证明远端没有执行。
+
+#### 重试会消耗更多资源
+
+回看“算出多层重试的放大”的推演。短暂错误可能重试后成功,但下游已经过载时,更多请求会恶化情况。指数退避让后续尝试间隔变大,抖动打散很多客户端的同步重试;仍需要最大尝试次数、总时间和重试流量预算。
+
+#### 熔断:一段时间内减少调用故障依赖
+
+回看“让熔断和隔离一起保护其余能力”的推演。关闭状态允许请求;失败达到判断条件后进入打开状态,快速拒绝;经过等待后半开,放少量探测判断是否恢复。它不修复依赖,也不清理所有已发出的请求,误判或参数不当会延长不可用时间。
+
+#### 隔离:不同用途不要共用全部救命资源
+
+回看“让熔断和隔离一起保护其余能力”的推演。把慢文件写入、交互查询和批量清单导出的并发槽位或连接资源分开,一个依赖耗尽自己的配额时,其他能力仍有资源。隔离可能让部分空闲资源暂时无法借用,换来更可控的故障影响范围。
+
+#### 降级必须保持结果诚实
+
+回看“让熔断和隔离一起保护其余能力”的推演。文件存储不可用时,可明确告诉用户导出暂不可用,并继续提供商品查询和已有任务状态。不能返回不存在的下载链接并标成完成。保留查询、取消等轻量能力,通常比让所有请求长期等待更有价值。
+
+### 延伸阅读
+
+- [Amazon Builders’ Library:超时、重试、退避与抖动](https://d1.awsstatic.com/builderslibrary/pdfs/timeouts-retries-and-backoff-with-jitter.pdf)
+- [Microsoft:熔断模式](https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)
+- [Microsoft:舱壁隔离模式](https://learn.microsoft.com/en-us/azure/architecture/patterns/bulkhead)
+
+---
+
+## 11 限流、背压与容量:队列只能借时间
+
+估算积压、清空时间与受理上限,并解释为何副本扩容会改变本地限流总量。
+
+### 从一个问题开始
+
+导出任务不断排队,增加队列长度后只是更晚失败;先算清来多少、能做多少。
+
+**这次的终点**
+
+估算积压、清空时间与受理上限,并解释为何副本扩容会改变本地限流总量。
+
+#### 开始前,确认你在这里
+
+- 完成第 10 课,知道等待和重试都会占用资源。
+- 准备纸笔,统一用任务/秒与秒计算,假设耗时稳定,实际波动另行记录。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 把任务量换算成下游调用量
+- 计算高峰后的净消化速度
+- 根据最大等待时间决定何时拒绝新受理
+
+### 一步步推演场景
+
+#### 1. 先数完整任务会放大多少调用
+
+每秒到达 10 个导出,每个写文件平均调用 4 次。先算文件请求数,再给每次文件大小加一列,指出还缺哪种容量信息。
+
+**对照你的推演**
+
+至少需要约 40 次文件请求/秒,重试会继续增加;文件字节吞吐也可能先达到上限。
+
+**从这里理解原因**
+
+每秒 10 个商品导出任务,每个平均发出 4 次文件服务请求,下游平均需求约为每秒 40 次,还未包括重试。若文件服务还限制字节吞吐,需要结合导出文件大小;只数 HTTP 请求不足以估算实际瓶颈。
+
+#### 2. 估算最简单的执行容量
+
+画 10 个执行槽,每个任务平均占 2 秒,假设没有其他瓶颈。计算满负荷平均每秒能完成多少,并标注假设。
+
+**对照你的推演**
+
+估算为每秒 5 个任务;这是理想平均值,实际耗时波动与数据库等共享资源会改变能力。
+
+**从这里理解原因**
+
+10 个执行槽,每个任务平均占用 2 秒,在持续忙碌且没有其他瓶颈的理想条件下,平均吞吐约 5 个任务/秒。这是估算,不是固定保证;耗时波动、共享数据库和外部配额都会改变结果。
+
+#### 3. 计算积压与消化时间
+
+高峰以每秒 8 个到达、5 个完成持续 60 秒;之后每秒仍到达 3 个。分两段计算新增积压与清空时间。
+
+**对照你的推演**
+
+先新增约 180 个;恢复后净消化为每秒 2 个,约需 90 秒,而非 36 秒。
+
+**从这里理解原因**
+
+若高峰每秒到达 8 个、处理 5 个,持续 60 秒会多积压约 180 个。高峰结束后如果仍每秒到达 3 个,只剩每秒 2 个的净消化能力,需要约 90 秒清空,不是简单用 180 除以 5。
+
+#### 4. 在任务失去价值前决定是否受理
+
+用户只愿等 30 秒,把当前积压与执行能力换算成等待估计,写出超过时限时的新任务响应与旧任务保护动作。
+
+**对照你的推演**
+
+不能只延长队列掩盖过期;应限制受理或降级,同时看最老年龄与任务耗时分布。
+
+**从这里理解原因**
+
+如果用户允许最多等 30 秒,而当前积压按能力估算已超过这个范围,应限制新受理、减少非必要工作或降级。队列长度之外还要看最老任务年龄;几百个轻任务与几个超长任务不能只按相同数量比较。
+
+#### 5. 把公平性与全局精度写清楚
+
+先给每个用户划配额,再让 API 从 1 个副本变成 5 个,每个各限 100 次/秒。计算总上限并选择需要严格共享还是允许近似。
+
+**对照你的推演**
+
+独立本地限制可能总计约 500 次/秒;要严格全局上限需协调或分配额度,也会增加成本和故障取舍。
+
+**从这里理解原因**
+
+给用户分别设置导出速率和并发,可防止一家占满全部 Worker。若每个 API 副本各限 100 次/秒,增加到 5 个副本可能放行约 500 次/秒;严格全局上限需要共享协调或预分配配额,代价是延迟、复杂度和故障时的取舍。
+
+### 检查推演结果
+
+- 能区分入口 QPS、文件服务调用数、运行并发和排队数量。
+- 能正确计算持续超载的积压以及有新流量时的净消化速度。
+- 能说明稳定平均值公式不能直接保证每个请求的尾延迟。
+
+### 常见误区
+
+#### API 返回很快,用户的任务却等待几小时。
+
+原因:把迅速入队当成系统吞吐提高,没有关注任务总完成时间。
+
+处理:以受理到完成的目标约束队列和接入,观察最老等待、完成速率与过期任务。
+
+#### 扩容 API 后突破了文件服务总配额。
+
+原因:限流计数只在单个实例内,实例增加使总放行量增加。
+
+处理:明确限制是局部还是全局,并通过共享协调或有边界的配额分配保护总量。
+
+### 面试问答
+
+如何设计一个多用户商品服务的限流与背压?
+
+从受保护资源出发,分别预算入口请求、运行并发、等待容量和文件读写吞吐。入口按用户保证公平,下游调用点防止分批导出突破配额,队列满或等待超预算时把反馈传回受理端。说明算法允许多大突发、计数是否全局、故障时如何处理,以及拒绝后的用户交互。
+
+追问:
+
+- 令牌桶容量与补充速率分别控制什么?
+- 为什么扩容 Worker 可能反而让整体更慢?
+
+### 自己设计一次
+
+队列已有 240 个任务,Worker 平均每秒完成 6 个,新任务仍每秒进入 4 个。多久能清空?若到达率改成每秒 7 个会怎样?
+
+提示:清空速度是完成速率减去新到达速率;结果成立的前提是这些平均值保持不变。
+
+参考答案:
+
+每秒净减少 6−4=2 个,理想估算需要 240÷2=120 秒清空。若到达率为 7,则每秒净增加 1 个,队列不会自然清空;需要降低受理、提升真实处理能力或减少任务工作量。增加队列长度只延迟容量耗尽。平均计算还忽略长尾、重试和资源争抢,所以不能据此向每个用户承诺恰好 120 秒;应结合任务年龄、分位数和明确的受理规则。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 四种上限各管一件事
+
+回看“估算最简单的执行容量”的推演。速率上限限制一段时间进入多少请求,并发上限限制同时执行多少,队列容量限制多少任务等待,下游配额可能限制文件写入次数或字节吞吐。一次商品导出可能分批写多个文件片段,只在入口数请求未必能保护存储容量。
+
+#### 队列吸收短时突发,不能修复长期超载
+
+回看“计算积压与消化时间”的推演。像餐厅等位区,队列让客人暂时等待,不会让厨师做菜更快。若持续每秒进入 8 个任务而只能完成 5 个,每秒积压增加 3 个。无限队列最终把内存、磁盘、等待时间或任务有效期耗尽。
+
+#### 背压:把下游忙碌传回上游
+
+回看“在任务失去价值前决定是否受理”的推演。消费者忙时减少预取,队列满时让生产者有限等待或拒绝,入口据此降低受理。反馈链必须传到真正产生工作的地方,才能阻止积压转移到另一个无界缓冲。用户要知道任务是已受理、待提交还是被拒绝。
+
+#### 常见限流算法各有形状
+
+回看“把公平性与全局精度写清楚”的推演。固定窗口简单,但窗口交界处可能出现短时突发;滑动窗口让时间边界更平滑但有状态成本;令牌桶允许积攒有限突发额度;漏桶式整形以较平稳速度放出,可能增加等待。选择依据是突发容忍、精度和协调开销。
+
+#### Little 定律连接平均数量与平均等待
+
+回看“估算最简单的执行容量”的推演。在统计口径一致、系统长期稳定且平均值存在等条件下,平均在系统中的工作数 L = 有效到达率 λ × 平均停留时间 W。若统计的是整个系统,W 包括排队与执行;若只统计队列,则应使用队列等待时间。持续发散的积压不能直接套成稳定容量承诺。
+
+### 延伸阅读
+
+- [Microsoft:节流与限流选择](https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling)
+- [Microsoft:调用速率协调模式](https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern)
+- [Little:排队公式 L = λW 原始论文](https://pubsonline.informs.org/doi/epdf/10.1287/opre.9.3.383)
+
+---
+
+## 12 可观测性、容灾与一道完整的系统设计题
+
+形成含正常路径、失败恢复、观测证据和恢复顺序的商品导出设计。
+
+### 从一个问题开始
+
+现在为同一商品清单导出交一份完整设计:用户能离开页面,回来查看进度并下载结果。
+
+**这次的终点**
+
+形成含正常路径、失败恢复、观测证据和恢复顺序的商品导出设计。
+
+#### 开始前,确认你在这里
+
+- 完成第 01—11 课,保留边界图、失败时间线和容量计算。
+- 只使用名称、描述、整数价格、上下架、分类与评价这些既有商品字段;本课只做设计推演。
+
+先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。
+
+### 这节课要完成什么
+
+- 画出从受理到文件下载的最小方案
+- 把每个失败点连到证据和恢复动作
+- 说明资源有限时先恢复什么及取舍理由
+
+### 一步步推演场景
+
+#### 1. 把设计题限定到可推演的需求
+
+写下用户提交商品清单导出后离开、稍后回来查询、下载或取消的四步。先选一组教学用数量、峰值、文件大小和完成时限,标明是假设。
+
+**对照你的推演**
+
+需求应有可计算的输入上限与完成目标,商品字段仍限于已有简单管理范围,不需要增加支付或库存业务。
+
+**从这里理解原因**
+
+题目:设计商品清单导出,提交后可关闭网页,稍后查询进度和下载,支持取消。商品管理只包含名称、描述、整数价格、上架状态、分类和评价。先确认每次导出商品数量、峰值任务量、文件大小、完成时限与恢复目标;本课只做推演,不要求再拆出新业务。
+
+#### 2. 给出最小完整架构
+
+用前 11 课的图拼出从客户端到 API、任务库、发布器、队列、Worker、文件的路线;给提交和发布结果两处圈出原子边界。
+
+**对照你的推演**
+
+方案应保存固定商品快照、意图与派发记录;文件准备好后才发布可查询结果,缓存只服务允许旧读的位置。
+
+**从这里理解原因**
+
+客户端经负载均衡访问 API;API 校验身份和商品编号,在同一事务保存任务快照、幂等记录与派发意图;发布器送入有界队列;Worker 导出包含 price_cents 的固定快照,把文件保存到共享存储,再按状态和版本发布结果引用。客户端按 job_id 查询进度,完成通知可异步发送。缓存只加速允许旧读的商品查询。
+
+#### 3. 把失败放回发生的位置
+
+分别在入口响应、消息发送、Worker 执行、文件返回四个位置插入失败,为每个位置写出能查询的记录和下一步。
+
+**对照你的推演**
+
+能分别找回同一任务、重发持久意图、按版本接管、核实未知文件结果;仅看到进程活着不足以判定恢复。
+
+**从这里理解原因**
+
+入口超时靠稳定幂等键找回同一任务;Broker 暂停时保留发送意图并限制新受理;Worker 中断由持久状态和接管规则恢复;旧执行者不能覆盖已发布终态;文件服务超时保留未知状态并核实。每个恢复判断都要有数据证据,不能仅靠进程存活。
+
+#### 4. 选能说明用户受损的观测数据
+
+假设用户投诉“半小时还没导出”,从表中选择能定位受理、派发、等待、执行和存储阶段的数字及关联编号。
+
+**对照你的推演**
+
+最老等待年龄和完成时间比单纯进程数更贴近用户;job_id/attempt 用于日志关联,不逐任务生成指标标签。
+
+**从这里理解原因**
+
+观察受理量、失败率、完成时间分位数、最老待发布年龄、队列等待、外部依赖延迟和文件写入吞吐。用 job_id 找到业务历程,用 message_id 与 attempt 区分投递和执行;具体编号放日志或追踪,避免把每个任务编号都做成指标标签造成高基数。
+
+#### 5. 写出灾难恢复的依赖顺序
+
+假设整个区域失效,将权威数据、文件、密钥配置、入口、派发器和 Worker 排成恢复次序,再写一条防止重复重放的检查。
+
+**对照你的推演**
+
+需先确认数据恢复点和写入归属,再恢复服务能力并核对未完成效果;只有任务表而没有文件仍无法下载。
+
+**从这里理解原因**
+
+区域失效后,先确认权威数据恢复点和写入归属,再恢复入口、任务协调与执行能力,重新核对未完成任务及外部副作用。数据库任务记录、商品清单文件、派发记录和密钥配置都要可用,单独恢复一张任务表并不等于服务恢复。异步重放还需防止重复效果。
+
+#### 6. 用一个明确取舍收束答案
+
+资源只能先支撑部分能力,选择商品查询、任务查询或批量导出的恢复顺序,并声明可接受旧读、损失与等待边界。
+
+**对照你的推演**
+
+可以先恢复查询、稍后恢复导出,但必须说明目标和限制;不能同时无条件承诺零损失、零等待、零故障。
+
+**从这里理解原因**
+
+商品列表允许短暂旧读,可以使用副本与缓存;商品修改和导出终态发布由权威事务与版本条件保护。资源有限时优先恢复商品和任务查询,批量导出稍后恢复。说明目标、限制和升级条件,避免同时承诺零故障、零损失与零等待。
+
+### 检查推演结果
+
+- 能分别用指标、日志和追踪回答“影响多大”“哪项任务”“慢在哪段”。
+- 能正确解释 RTO 与 RPO,并指出数据库恢复不等于全业务恢复。
+- 能按需求→容量→架构→数据规则→故障恢复→取舍讲完一道设计题。
+
+### 常见误区
+
+#### 监控全绿,但用户商品清单一直没有生成。
+
+原因:只监控进程心跳与接口 200,没有监控异步任务完成情况。
+
+处理:增加业务完成率、最老等待和各阶段持续时间,按用户目标解释异常。
+
+#### 恢复数据库后直接重放全部任务,重复生成文件或发送完成通知。
+
+原因:没有确认哪些外部效果已经发生,也没有保留稳定任务编号和接管边界。
+
+处理:按任务状态与文件、通知证据分类恢复,未知结果先核实,重放仍受幂等与版本规则约束。
+
+#### 写了 RPO=0,就认为任何故障都不会丢数据。
+
+原因:把期望目标当成已经实现的保证,没有定义故障模型和确认边界。
+
+处理:说明何时向用户确认、哪些副本持久化、相关对象如何保持可恢复,并区分设计目标与实际证据。
+
+### 面试问答
+
+面试中如何设计一个支持可靠导出的高可用商品服务?
+
+先明确商品操作、导出量、文件大小、完成时限和一致性要求,再估算数据库与文件存储需求。画入口、商品与任务状态、队列、执行器和结果存储,沿请求解释受理与完成。补充幂等、未知结果、租约接管、限流和业务观测,最后用 RTO/RPO 与故障范围说明恢复能力和成本取舍。
+
+追问:
+
+- 文件存储整体不可用时,哪些商品服务能力仍可提供?
+- 如何避免恢复区域与旧区域同时发布结果?
+- 为什么 job_id 不适合作为常规指标的无限增长标签?
+
+### 自己设计一次
+
+用不超过六段话设计可恢复的商品清单导出服务。峰值 20 个任务/秒,每个任务平均 3 次文件服务调用,文件服务配额为 50 次/秒;允许有限排队,RTO 为 30 分钟,RPO 为 5 分钟。
+
+提示:先找容量矛盾,再解释受理、持久状态、执行、观测和恢复,所有数字都是本题假设。
+
+参考答案:
+
+第一段:用户需要商品清单导出、状态查询、文件下载和取消,明确排队上限及允许 5 分钟恢复点差距的数据范围。第二段:下游需求为 20×3=60 次/秒,超过 50 且未计重试,理想持续能力上限约 16.7 个任务/秒,必须限流、减少文件调用、提高配额或只承接有限突发。第三段:API 同一事务保存固定商品快照、幂等任务与 Outbox,发布器送有界队列,Worker 受控执行并按版本发布文件引用。第四段:重投沿用 job_id,文件未知结果按对象标识核实;取消与旧执行者发布均受持久状态约束。第五段:观察受理到完成时间、最老等待、派发积压和依赖错误,用 job_id 关联日志与追踪。第六段:按预算选择备份或温备,恢复同时考虑数据库、商品清单文件、配置和未完成通知,以 30 分钟内恢复约定能力为目标;5 分钟 RPO 不允许任务无声消失,应识别受影响提交并恢复可查询状态。更严目标需要额外复制与协调成本。
+
+### 核心概念回顾
+
+回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。
+
+#### 三类观测信号分别回答不同问题
+
+回看“选能说明用户受损的观测数据”的推演。指标说明一段时间内多少、多久、失败多少;日志保留某次状态变化和原因;链路追踪连接一次请求跨组件的耗时。异步任务还需要稳定的 job_id 关联多次投递、多个尝试和多段追踪;它和单次 trace_id 不是同一个概念。
+
+#### 业务 SLO 关注用户有没有拿到价值
+
+回看“选能说明用户受损的观测数据”的推演。“API 99% 返回成功”不能证明“商品清单 99% 按时完成”。可分别定义受理成功率、排队到开始、受理到完成、取消确认等目标。统计必须说明时间窗口、有效请求分母和未到完成期限的任务,避免漂亮数字掩盖积压。
+
+#### RTO 与 RPO 是目标,不是自动保证
+
+回看“写出灾难恢复的依赖顺序”的推演。RTO 表示灾难发生后,业务最多可接受多久恢复;RPO 表示最多可接受丢失多长时间范围的数据变化。例如 RTO 30 分钟、RPO 5 分钟,分别约束恢复时间和恢复点距离,不能互换。实际能否达到取决于整条恢复链。
+
+#### 容灾选项体现成本与恢复速度的取舍
+
+回看“写出灾难恢复的依赖顺序”的推演。备份恢复通常常驻资源较少,但恢复准备较多;保留核心数据的 pilot light、缩小运行规模的温备、多个站点同时服务,逐步增加常驻能力与协调要求。多地活跃也不能自动解决错误配置、误删、写冲突或外部依赖中断。
+
+#### 系统设计题的核心是解释因果
+
+回看“用一个明确取舍收束答案”的推演。先明确用户要什么和可接受的等待、损失、成本,再画组件和数据流。每添加一个组件,都应能回答它解决哪个问题、失败会影响谁、如何恢复和付出什么代价。术语数量不能替代这些因果关系。
+
+### 延伸阅读
+
+- [OpenTelemetry:可观测性基础](https://opentelemetry.io/docs/concepts/observability-primer/)
+- [Google SRE:分布式系统监控](https://sre.google/sre-book/monitoring-distributed-systems/)
+- [AWS:RTO 与 RPO 目标](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/disaster-recovery-dr-objectives.html)
+- [AWS:云端灾难恢复方案](https://docs.aws.amazon.com/whitepapers/latest/disaster-recovery-workloads-on-aws/disaster-recovery-options-in-the-cloud.html)

Dosya farkı çok büyük olduğundan ihmal edildi
+ 437 - 144
public/downloads/docker-lessons.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 421 - 168
public/downloads/linux-lessons.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 416 - 192
public/downloads/redis-lessons.md


+ 1891 - 0
public/downloads/review-lessons.md

@@ -0,0 +1,1891 @@
+# 复盘与自测
+
+术核 AI · 后端工程课件
+
+每个单元对应一个已学模块。先独立回答,再看文末参考要点;合上答案后仍能解释原因、推演条件变化,并拿出四项掌握证据,才达到本轮标准。记录反映学员自评,不自动判定文字答案的正确性。
+
+## 01 API 服务开发:我真的掌握了吗
+
+从一次商品请求讲清运行、校验、状态码与等待,再用独立操作证明商品接口、进度流和连接停止符合约定。
+
+### 如何自测
+
+一位同学能照着视频点出商品列表,却解释不了重启后商品为何消失,也分不清 422 和服务未启动。现在由你接手这份本机小目录:先闭卷回答,再用自己的请求与观察证明理解。
+
+- 已完成 API 服务开发模块,保留 api-lab、对应虚拟环境及可运行的 main.py;先确认 /health 可访问,所有变式只使用本机课堂数据。
+- 每题先独立写下预测、理由和验证办法,再展开参考要点;实战题先做并记录证据,卡住时再回看指定课时。
+- 自评用于发现薄弱处。能认出参考答案不等于能独立完成;没有实际执行的操作明确记为未验证。
+
+本轮目标:交出自己的解释、请求结果与故障定位记录,指出哪些能力已有独立证据、哪些仍需回课练习。
+
+原模块讲义:[api-lessons.md](./api-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 文件还在,为什么商品却没了?
+
+服务启动后访问根地址得到 404,而 /health 正常;创建一件陶瓷杯后保存 main.py,服务重载,商品列表又变空。请分别解释这两个现象,并说明如何核对服务确实用了 api-lab 的 Python。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · 启动第一个 FastAPI 服务](../?lesson=01);[06 · 完成内存版商品 CRUD](../?lesson=06)
+
+#### Q2 三次填错,应该改地址还是改商品字段?
+
+读取 /products/abc、列表条件 limit=0、创建商品时填写空名称,分别应从哪里找到错误位置?再解释:正确输入却因服务端漏返回 id 得到 500,为什么不能要求调用方换个商品名称重试?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · 从路径、查询参数和 JSON 中接收数据](../?lesson=03);[04 · 用 Pydantic 定义输入和输出契约](../?lesson=04)
+
+#### Q3 重复替换与重复创建,结果应一样吗?
+
+先创建帆布袋,再对返回的编号执行两次完全相同的 PUT,随后执行两次 DELETE。请预测各阶段的结果和最终状态,并解释为什么把创建请求原样重发两次可能得到两件商品。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · 设计商品资源与 HTTP 行为](../?lesson=05);[06 · 完成内存版商品 CRUD](../?lesson=06)
+
+#### Q4 私有入口为什么没有按预期出现或放行?
+
+你把 /private 写进 routers/private.py 后,文档里看不到它。接入后,请求分别得到 503、401 和 200。请给出检查顺序,并说明为什么统一错误格式时仍要保留原有状态码和响应头。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[07 · 返回可定位、格式一致的错误](../?lesson=07);[08 · 复用依赖与拆分路由文件](../?lesson=08)
+
+#### Q5 两个慢请求与一个浏览器错误,是否同一原因?
+
+两个各等 1 秒的请求有时总计约 1 秒,有时约 2 秒且拖慢 /health。另一个商品网页用 localhost 打开时失败,但 curl 能成功。请分别提出最小对照实验,说明每个实验能证明什么。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[09 · 使用 async / await 处理 I/O 等待](../?lesson=09);[10 · 使用 HTTPX 调用服务与调试 CORS](../?lesson=10)
+
+#### Q6 清单进度应该什么时候显示、什么时候结束?
+
+商品保存只需一次结果,清单进度要逐段显示,咨询消息需要双方继续发送。请给三种需要各选一种本课通信方式;再解释为什么对 SSE 响应调用完整文本读取,或将每个网络块直接当成一条 JSON,都可能让体验出错。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[11 · 完整响应、流式响应、SSE 与 WebSocket](../?lesson=11);[13 · 在浏览器中读取流,并在中途停止](../?lesson=13)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 独立验收一件新商品的完整生命周期
+
+不复制练习答案,为本机目录增加一件“旅行收纳袋”,价格 2590 分,先不上架。独立完成创建、读取、完整替换、筛选和删除,并加入一次非法价格输入。先写出验收顺序及预期,再执行并保存请求记录。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · 完成内存版商品 CRUD](../?lesson=06);[16 · 把能运行的接口变成可重复验收的服务](../?lesson=16)
+
+#### C2 连接恢复了,停止按钮还有效吗?
+
+使用本机咨询回显服务,独立安排“正常收发、服务中断后恢复、用户主动停止”三段实验。同时检查清单流的完整内容与真实逐段到达。先写出每一段要记录的证据,再操作;不要将自评勾选当作自动测试通过。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[15 · 连接断开后:重连、心跳与取消](../?lesson=15);[16 · 把能运行的接口变成可重复验收的服务](../?lesson=16)
+
+### 掌握标准与证据
+
+- [ ] 我能独立解释一次商品请求从地址匹配、输入检查到响应生成的过程。
+
+  证据:不看要点写出 404、405、422、500 的不同触发位置,并为其中至少两项提供自己复现的状态与错误位置。
+
+- [ ] 我能独立启动正确环境并完成一件商品的创建、替换和删除。
+
+  证据:交出 .venv 解释器路径、真实商品 id 以及操作后的读取结果,说明重载对内存商品的影响。
+
+- [ ] 我能独立区分内容检查与真实网络、浏览器行为验证。
+
+  证据:交出一组可重复断言和一次逐段到达时间记录,明确指出 TestClient 未覆盖的浏览器检查。
+
+- [ ] 我能独立为等待、来源错误和连接中断提出有依据的排查步骤。
+
+  证据:复现一个受控故障,保留对照结果、修复后恢复请求及停止后无新增连接的记录;未执行部分如实标记。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 文件还在,为什么商品却没了?
+
+- /health 正常说明进程能够回应;根路径没有注册路由时返回 404,与端口连接失败不同,不能仅凭根路径判断服务没启动。
+- 路由由方法和路径共同匹配到函数;main:app 指向 main.py 中的 app,磁盘上存在文件本身并不等于服务进程正在运行。
+- 本课商品字典只在当前进程内存在,自动重载重新启动应用会清空它;Python 源文件保留不能证明商品数据持久保存。
+- 读取实际解释器路径,确认位于 api-lab/.venv,再核对安装和启动使用同一个 Python;提示符出现环境名只是辅助线索。
+
+容易误判:把所有 404 都归因于服务没启动,或认为源码没删所以商品不会丢。前者混淆路由结果与连接失败,后者混淆磁盘代码与进程内数据。
+
+#### Q2 三次填错,应该改地址还是改商品字段?
+
+- 非法编号、越界列表数量、空商品名都可触发 422,但错误位置分别指向 path.product_id、query.limit 和 body.name;必须阅读位置,不能只看相同状态码。
+- 路径与查询字符串先是文本,声明的类型和范围在路由执行前参与校验;ProductCreate 则说明 JSON 商品字段及默认值。
+- 价格按整数分接收,输入必须符合该字段的整数、非负和上限要求;省略字段、显式 null 与提交一个合法值不能一概当成相同输入。
+- ProductOut 要求返回 id,正常请求因服务返回数据缺字段而失败属于服务端输出错误;response_model 还筛选公开字段,不能用修改正确输入掩盖返回代码的问题。
+
+容易误判:认为只要出现字段问题就一定是调用方 422,或者只在路由函数里捕获所有校验失败。输出错误应修服务端,部分输入失败还发生在路由执行之前。
+
+#### Q3 重复替换与重复创建,结果应一样吗?
+
+- 创建成功应得到 201 和实际 id,后续请求使用这个 id,而不是固定假定为 1;再次 GET 才能核实保存结果。
+- 本模块 PUT 是完整替换,name、description、is_active、price_cents 四个可写字段必须明确提交;两次相同 PUT 后内容一致且 id 不变。
+- 第一次 DELETE 成功返回 204 且响应体为空,之后读取为 404;再次删除可以返回 404,但目标始终是不存在,幂等关注最终状态而非响应逐字相同。
+- 本模块普通 POST 没有自动去重协议,重复创建会分别分配编号;不能把 PUT 的重复行为推广到 POST。验收期间不改代码触发内存重载。
+
+容易误判:认为幂等要求每次状态码完全一样,或拿创建模型的默认值模拟局部更新。前者忽略最终状态,后者会把未提交字段重置而不是保留旧值。
+
+#### Q4 私有入口为什么没有按预期出现或放行?
+
+- 先确认主入口在创建 app 后执行 include_router;仅导入模块或保存文件不会自动将 APIRouter 里的接口加入应用,也不能再新建一个 app 覆盖旧路由。
+- 503 可对应服务没有配置 DEMO_API_KEY;在启动服务的同一个终端设置后重新启动,再验证进程读取的配置,另一窗口改变量不会改变旧进程。
+- 依赖从 X-API-Key 请求头取值,缺失或错误时在路由之前返回 401,正确课堂值才放行到 200;把值放进查询参数不能满足 Header 声明。
+- 统一 error 外层仍保留 HTTP 状态及 Allow、WWW-Authenticate 等必要头。固定错误结构方便调用方定位,不应把失败都变成 200,也不意味着形成完整用户身份系统。
+
+容易误判:把依赖当成只供文档展示的注释,或通过放宽全部路由来消除错误。依赖实际控制请求是否进入函数,注册、服务配置和来访者输入要分别检查。
+
+#### Q5 两个慢请求与一个浏览器错误,是否同一原因?
+
+- 保持同一单进程服务、相同并发客户端和相同 delay,只比较 await asyncio.sleep 与在 async 路由内直接 time.sleep;前者等待可重叠,后者会占住事件循环。
+- 同时观察 /health 与总耗时,记录环境和合理开销;async 关键字本身不会把阻塞函数变成异步,也不能据此宣称大量 CPU 计算会加速。
+- 浏览器实验比较完整 Origin:协议、主机和端口;localhost 与 127.0.0.1 是不同来源,查看 OPTIONS 预检和授权响应头,而不是先修改商品内容。
+- curl 与服务端 HTTPX 不执行浏览器 CORS 限制。HTTPX 调用上游超时是另一段链路,本课可返回 504,不能把它与浏览器来源不匹配合并诊断。
+
+容易误判:认为“curl 成功所以网页一定能读响应”,或“写了 async 所有等待就会并行”。前者漏了浏览器检查,后者漏了实际执行的阻塞调用。
+
+#### Q6 清单进度应该什么时候显示、什么时候结束?
+
+- 普通商品写入可用 HTTP 请求与完整响应;单向持续进度可用 SSE;连接中双方都要主动发消息时可用 WebSocket。只是偶尔查状态时,定期 HTTP 查询也可能足够。
+- SSE 在同一个 HTTP 响应体里按空行划分事件;本课 token 表示文字片段,done 表示正常完成,不能把连接消失直接当成业务完成。
+- 完整文本或 JSON 读取要等响应体结束;手动读取应连续解码 UTF-8、缓存未完成文本再按事件边界提取,因为字符和事件都可能跨块,一块也可能含多条事件。
+- EventSource 适合本课 GET 并在 done 后主动关闭;需要 POST 请求体时可用 fetch 并自行解析。主动取消应有明确停止动作和取消结果,不当作一般服务故障。
+
+容易误判:把一次 read 与一条完整事件画等号,或因为 SSE 使用 HTTP 就认为它不能逐段显示。网络分块和业务事件边界并不重合,读取方法也会决定何时向用户展示。
+
+#### C1 独立验收一件新商品的完整生命周期
+
+- 从创建响应取得实际 id,记录 201,再读取同一商品确认名称、描述、上架状态和整数价格;不依赖现有样品编号,也不在过程中重载服务。
+- 完整替换明确提交四个可写字段,把价格改为 2790 分并上架;再次读取核实 id 不变且四项内容符合新值,同时比较明确 false 筛选与未传筛选的差异。
+- 发送负价格等确定不符合契约的输入,记录 422 和具体字段位置,并重新读取原商品证明失败请求没有改掉合法数据。
+- 删除后核对 204 空体,再次读取得到 404;把核心状态和结果写成可重复断言,说明哪些是实际记录,哪些还未执行。
+
+容易误判:只保存几张成功响应,便说完整流程已经验证。没有后续读取、失败后的数据检查和删除空体检查,无法证明各操作之间的真实状态变化。
+
+#### C2 连接恢复了,停止按钮还有效吗?
+
+- 正常连接后先等 OPEN 再发咨询和 ping,记录 echo 与 pong;关闭并恢复服务后观察有上限、逐渐延长的重试以及新连接的真实回信。
+- 在下一次重试前主动停止,等待超过本课最长重试间隔并检查没有新增连接;断线时未发送的业务消息不应在重连后自动出现。
+- 内容测试检查清单 token 拼接结果与完整响应一致且 done 最后出现;真实网络另记首段与后续片段到达时间,不能用 TestClient 的完整结果证明延迟表现。
+- 分别记录正常完成、主动取消和异常断开;解释心跳是本课应用层消息、重连是新连接,恢复通道并不自动恢复消息执行状态。
+
+容易误判:看到重新出现“已连接”就宣布恢复与停止都可靠。新连接不证明旧消息已送达,停止后还有计时器也可能继续重连,必须验证可观察的后续行为。
+
+---
+
+## 02 数据库基础:我能设计并验证商品数据吗
+
+从字段和关系设计开始,用合法与非法写入验证规则,再解释事务、并发、索引、连接与恢复的实际边界。
+
+### 如何自测
+
+你要为一个只管理商品名称、价格、上架状态与分类的小目录设计数据库。同学说“表建出来、查询不报错就算学会”,你需要用具体反例、执行结果和恢复证据检查这个判断。
+
+- 已完成数据库基础模块,保留 shop-service 与本机 shop_lab;确认练习账号、库名和当前客户端位置,不把本机实验指向业务数据。
+- 每题先独立给出设计或预测,再展开要点对照。操作题使用新建课堂样品并记录实际编号,不复制固定 id,不省略失败后的数据核查。
+- 结构修改、双会话和恢复各有已学前提;开始操作前重查对应课时的实验边界。没有执行的项写未验证,自评不代表客观考试结论。
+
+本轮目标:交出商品字段与关系设计、有效和无效写入结果、并发观察及独立恢复记录,按证据判断自己能否脱离示例完成。
+
+原模块讲义:[database-lessons.md](./database-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 字段设计能挡住哪些具体错误?
+
+一个保温杯可以改名,也允许和其他商品同名;售价 19.90 元,新建时默认下架,免费样品售价为 0。请独立设计商品核心字段,说明类型、主键、非空、默认值与检查约束分别在防什么错误,并说明如何保存创建和修改时间。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 从商品需求设计表结构、字段与数据约束](../?module=database&lesson=02)
+
+#### Q2 一个商品属于两个分类,怎样避免重复与孤儿?
+
+桌面收纳盒同时归入“办公”和“桌面”,另一件样品暂时没有分类。请画出三张表的关系,决定主外键和两个查询方向的索引,再预测重复归类、删除被引用分类、下架商品、删除商品的结果。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[04 · 设计商品分类关系表、主外键与删除策略](../?module=database&lesson=04)
+
+#### Q3 第二条写入失败,第一件商品还在吗?
+
+Python 创建一件名称含单引号的技术书,再给它关联一个不存在的分类。请说明如何传入名称,如何获得新编号,以及第二次写入报错后应怎样结束事务。另解释为什么不能把一次建表语句顺手夹进这组业务写入。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · 用PyMySQL参数化完成增删改查](../?module=database&lesson=03);[05 · 明确InnoDB事务、保存点与DDL提交边界](../?module=database&lesson=05)
+
+#### Q4 另一个人已保存,为什么我还看到旧值?
+
+A、B 同时读取同一商品,B 把描述改为 v2 并提交。请预测 A 在 RR、RC 下继续普通查询的不同结果,再解释 RR 的 FOR UPDATE 为什么可能看到新值。若两位编辑都带着 version=1 保存,怎样拒绝后来的覆盖?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · 用双会话观察RR、RC与当前读](../?module=database&lesson=06);[07 · 观察行锁、间隙锁、死锁与版本冲突](../?module=database&lesson=07)
+
+#### Q5 只返回 20 行,为何还是读了很多数据?
+
+上架商品按创建时间和 id 倒序显示,每页 20 行。请提出组合索引候选,并说明如何从计划判断其作用。翻到很深的位置时,为什么 OFFSET 的工作量可能很大?已有上一页末行后,怎样确定下一段范围?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[08 · 设计组合索引、覆盖索引与JSON路径索引](../?module=database&lesson=08);[09 · 读懂EXPLAIN ANALYZE并优化深分页](../?module=database&lesson=09)
+
+#### Q6 借不到连接,换 root 能解决吗?
+
+两个请求占住小池中的两条连接,第三个请求借用超时。同学建议改成无限连接并让商品程序使用 root。请指出该建议混淆了什么,说明怎样验证连接归还、事务结束和日常账号权限。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 用有界连接池管理PyMySQL连接与事务](../?module=database&lesson=10);[11 · 按账号host范围拆分权限与连接配置](../?module=database&lesson=11)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 从需求写出一套可被反例检验的表设计
+
+先不看原表定义,为“旅行收纳袋 25.90 元,可归入户外和收纳分类”写出字段设计表与关系图,再在本机练习环境用新样品验证。已有表只核对和复用;不要为重做题目删除原表。设计中必须写清缺值、重复、删除和排序规则。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 从商品需求设计表结构、字段与数据约束](../?module=database&lesson=02);[04 · 设计商品分类关系表、主外键与删除策略](../?module=database&lesson=04)
+
+#### C2 证明备份能恢复,再验证一次兼容加列
+
+为本机商品库准备一次独立恢复验收。先写出源库、独立目标库、暂停写入的范围与核对项目,再生成备份并恢复到专用验收库。确认数据后,对照课程验证恢复库可写和源库兼容加列;已有相同列或非空目标时先盘点,不重复执行。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[11 · 按账号host范围拆分权限与连接配置](../?module=database&lesson=11);[12 · 用mysqldump恢复并验收MySQL结构变更](../?module=database&lesson=12)
+
+### 掌握标准与证据
+
+- [ ] 我能独立从轻量商品需求推导字段、主外键、约束和删除规则。
+
+  证据:交出一份字段表与关系图,逐项回答规则服务于什么操作,并用真实结构输出和至少四个失败样例印证。
+
+- [ ] 我能独立完成参数化写入并正确结束成功和失败事务。
+
+  证据:复现同一真实编号的创建、修改和清理,以及第二步失败后整笔回滚;保留合法数据与连接恢复的检查结果。
+
+- [ ] 我能独立用双会话和执行计划验证并发与查询成本。
+
+  证据:不看答案记录一次旧值/新值对照和一次索引或分页对照,写明顺序、实际行数、最终版本及实验局限。
+
+- [ ] 我能独立区分连接、锁、权限与恢复问题,并核对修复后的状态。
+
+  证据:完成一个受控故障的恢复请求与一份独立恢复验收记录;清楚标明未执行的项目,不能仅凭没有报错自评通过。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 字段设计能挡住哪些具体错误?
+
+- 用稳定 id 主键识别商品,名称使用有长度限制的文本且允许重名;名称会变,不适合当身份,也不能用自增编号大小当商品数量。
+- price_cents 用有范围的整数分,1990 表示 19.90 元,0 表示免费而非未知;NOT NULL 与非负 CHECK 各自保留,应用还应验证整数与范围。
+- is_active 默认 FALSE,并用约束限制 0/1;名称应非空白,描述有明确空串语义与 16000 字符上限,不能以“数据库接受文本”为由忽略业务边界。
+- DATETIME(6) 按统一 UTC 约定保存,字段本身不带时区;created_at 保持初次值,修改时显式更新 updated_at 并递增 version,默认值不会自动代劳。
+
+容易误判:把所有列统一做成可空文本,或认为 CHECK 足以替代 NOT NULL。这样无法表达数值和缺值语义;CHECK 对 NULL 得到的 UNKNOWN 也不能承担非空检查。
+
+#### Q2 一个商品属于两个分类,怎样避免重复与孤儿?
+
+- products 与 categories 各自保存商品和分类事实,product_categories 一行保存一组归类;两个外键的类型、符号与父表主键一致,不复制分类名或存逗号编号串。
+- 关联表以 product_id、category_id 组成联合主键,拒绝重复归类;以 category_id、product_id 建反向访问路径,分别支持两个查询方向。
+- 分类名按本课规则唯一,默认 ai_ci 比较下只变大小写仍可能冲突;商品名允许重名,不能把两种名称的唯一性规则混为一谈。
+- 下架保留归类;删除商品由 CASCADE 清理关联但保留分类;删除被引用分类由 RESTRICT 拒绝。未分类商品用 LEFT JOIN 保留,统计关联列而非全部连接行才能得到 0。
+
+容易误判:给关系表随便加一个 id 后就允许重复组合,或把下架等同于删除。多一个编号不能代替组合唯一规则,状态修改也不会自动触发删除级联。
+
+#### Q3 第二条写入失败,第一件商品还在吗?
+
+- 通过驱动参数传商品名,使单引号和看似 SQL 的片段保持为普通值;不使用字符串拼接生成含用户内容的 SQL,列名等标识符另用固定白名单。
+- 从当前连接的插入结果取得生成编号,再按这个 id 查询或归类;不用 MAX(id) 猜刚创建的是哪一条,也不把执行行数当作记录内容。
+- 若商品与分类必须同时成功,外层控制同一事务,异常后整体 rollback,最后关闭或归还连接;外键失败通常只撤销失败语句,不能因还能 SELECT 就继续提交。
+- CREATE、ALTER 等结构操作有隐式提交边界,不能用普通 ROLLBACK 假定全部撤销;若分类只是可选步骤,须先明确业务规则才考虑保存点。
+
+容易误判:认为数据库一报错误就已经替你回滚全部业务,或 with 游标退出等于提交。错误范围和客户端资源范围不同,必须主动处理事务结果。
+
+#### Q4 另一个人已保存,为什么我还看到旧值?
+
+- RR 的普通一致性读通常沿用事务首次一致性读建立的快照,因此 A 可能仍见旧值;RC 每条一致性读可见期间新提交的值,两者都不读另一事务未提交数据。
+- FOR UPDATE 使用锁定的当前读规则,不能套用普通查询的旧快照结论;它会取得锁,要及时结束短事务,不在用户编辑页面期间持有。
+- 把旧版本比较、字段更新和版本递增放在同一条条件更新里;首个 version=1 更新成功后版本变为 2,第二个旧版本匹配 0 行,不应宣称保存成功。
+- 锁等待超时与死锁不是相同事件,回滚范围也可能不同;操作后结束失败事务,再核对最终描述、版本以及是否还有活动事务。
+
+容易误判:认为 RR 会自动给所有旧页面保存返回版本冲突,或者先读版本再无条件更新就没有竞争。读取快照不替代原子版本条件,分开的检查与写入之间仍能插入另一修改。
+
+#### Q5 只返回 20 行,为何还是读了很多数据?
+
+- 候选索引围绕等值上架条件和倒序创建时间、id 组织,详情 id 已有主键;索引来自固定查询,不是每个字段都顺手添加。
+- 记录数据量、上架比例和同一查询的前后计划,比较所选 key、扫描行数与排序情况;EXPLAIN ANALYZE 实际执行 SELECT,不能把估计 cost 当毫秒。
+- OFFSET 即使只输出 20 行,通常仍处理之前被跳过的大量记录;应查看底层节点而不仅看顶层返回数量。
+- 按时间更早或同时间 id 更小的范围继续读取,保留相同筛选和排序;比较性能时单列获取深游标的成本,实际计划仍需验证。索引也增加写入维护和空间成本。
+
+容易误判:看到索引名字就宣布优化完成,或藏起取得第 40000 行游标的成本。优化需要同条件下的实际工作量对照,查询结果少不等于扫描工作少。
+
+#### Q6 借不到连接,换 root 能解决吗?
+
+- 池耗尽是应用等待空闲连接超时,与数据库行锁等待或认证失败不同;先核对借还范围、并发量和查询耗时,不能用换账号掩盖。
+- 每个并发任务用自己的连接和短事务,成功提交、失败回滚,最后归还;池代理 close 可保留底层连接复用,看到 Sleep 不能单独证明泄漏。
+- 验证释放占用后新请求又能借到连接并查询,容量设置有上限与等待预算;不能通过跨线程共用同一活动事务或无限扩容来绕过。
+- 日常账号保留本课商品读写权限,结构维护使用独立身份;实际检查 CURRENT_USER 与授权,做一项允许的写入和一项预期被拒的 ALTER,拒绝不应被当作待修故障。
+
+容易误判:把容量、锁与权限都归为“数据库连不上”。root 无法修复遗留事务,无限连接还会转移压力;应按失败发生层次寻找证据。
+
+#### C1 从需求写出一套可被反例检验的表设计
+
+- 设计材料写清每表一行的含义,商品稳定主键、名称可重名、整数价格分、下架默认值、UTC 时间与版本维护;逐项说明类型、非空、默认和检查约束的用途。
+- 关系材料明确父子类型一致、组合唯一、分类名称唯一、双向查询访问路径,以及本课商品级联、被引用分类限制删除的选择。
+- 用自己创建的实际编号证明合法价格和两条归类能保存;分别提交空白名称、负价格、重复归类与不存在父编号,记录预期拒绝及失败后原合法数据仍在。
+- 读出两个分类和另一个未分类样品的 0,验证下架仍保留关联;只清理自己新增的样品,并以建表实际输出核对设计没有停留在纸面。
+
+容易误判:只交一张字段清单,不验证规则是否落到真实表;或为快速重做直接删掉贯穿课程的表。设计能力需要实际约束与反例证据,复习也要维护既有实验边界。
+
+#### C2 证明备份能恢复,再验证一次兼容加列
+
+- 先核对目标数据库和账号,保护本机凭据文件,备份成功后检查文件校验和;不会把导入目标误指回源库,也不用跳过错误的方式把失败导入宣称成功。
+- 在暂停本次练习写入的条件下比较商品数量、编号范围、上架数量与价格分总和,并核对两个方向的外键孤儿计数以及实际主键、CHECK、删除规则。
+- 在恢复库试写新样品,取得实际自动编号后回滚;兼容加列明确目标和算法边界,验证旧查询与新查询均能工作,并说明 DDL 不能依赖普通回滚撤销。
+- 交出实际恢复结果与用时,区分已完成的逻辑恢复和仍需一致坐标、连续 binlog、停止点与隔离演练的时间点恢复;证据缺失处写未验证。
+
+容易误判:把文件存在、校验和正确或单项行数相同直接等同于恢复成功。文件完整不证明数据关系和后续写入可用,也不能证明已经具备时间点恢复能力。
+
+---
+
+## 03 API 与数据库实战:我能守住一次商品操作的边界吗
+
+把请求、Session、事务与真实表连成一条线,检查数据契约、分页、迁移、版本冲突、重复创建和通知重试是否都有验证依据。
+
+### 如何自测
+
+商品已经从内存搬进 MySQL,但能返回 201 还不够:两个人可能同时改同一件商品,创建响应可能丢失,通知可能重复。只用本课的小商品目录,检验你能否把这些结果解释清楚并验证到数据库。
+
+- 已完成 API 与数据库实战模块,保留 shop-service 的配置、迁移和探针。先确认当前连接的库名:前半模块使用 shop_lab,迁移后实验使用 shop_lab_migration,测试只指向独立 shop_lab_test。
+- 先独立作答,再展开参考要点;操作题只用随机标记的本机样品。并发和提交问题按原课真实连接实验执行,不把多个任务塞进同一个 Session。
+- 自评依据自己能交出的结果;测试输出、手工故障观察与未验证假设分别标记,不将熟悉术语或看懂答案当作客观通过。
+
+本轮目标:用独立解释、HTTP 与 SQL 双向结果、迁移检查和故障恢复证据,判断自己能否维护这条商品数据链路。
+
+原模块讲义:[api-database-lessons.md](./api-database-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 为什么能共享 Engine,却不能共享 Session?
+
+商品 API 的 /live 正常、/ready 失败;另一个同学让两个并发查询共用 Session,并认为连接池只需按一个进程计算。请说明三个问题的检查顺序,并计算两个实例、每实例三个 worker、每进程最多五条连接的稳态与新旧发布重叠上限。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 异步 Session 的生命周期](../?module=api-database&lesson=02);[06 · 连接池预算、超时与多 Worker](../?module=api-database&lesson=06)
+
+#### Q2 add、flush、commit 与返回 201 是同一件事吗?
+
+创建一件 1990 分陶瓷杯时,程序已拿到 id;有人据此立刻返回成功,再在后台提交。另一次 PATCH 没写价格与明确提交 null 被当作相同情况。请说明这两处为什么不符合本课的数据与事务约定,并指出时间输出要核对什么。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · ORM CRUD 与输入输出模型](../?module=api-database&lesson=03);[04 · 事务边界、约束失败与提交结果未知](../?module=api-database&lesson=04)
+
+#### Q3 商品分页了,评价读取就一定有上限吗?
+
+列表只取 20 件商品,但每次访问商品的评价属性都追加查询;改成批量预加载后,有一件商品仍带出很多评价。请说明两次性能问题的不同原因,并设计本课范围内的商品和评价分页边界。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · 商品评价关系与 N+1 查询](../?module=api-database&lesson=05);[08 · Keyset 分页与数据库错误映射](../?module=api-database&lesson=08)
+
+#### Q4 迁移版本号没前进,表一定没变吗?
+
+自动生成的迁移执行中途失败,版本号没有推进;同学准备直接再跑一次,并把同一个迁移放进普通回滚测试中。请说明先查什么,为什么本课从独立空库建立基线,以及新增 version 时为何先扩展、回填再收紧。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[07 · Alembic迁移、DDL边界与兼容发布](../?module=api-database&lesson=07);[12 · 真实MySQL隔离测试与故障验收](../?module=api-database&lesson=12)
+
+#### Q5 旧版本冲突与响应丢失重试,为什么需要两种机制?
+
+两位同学都读到陶瓷杯 version=1;另一位创建新商品时没收到响应,决定重新发请求。请分别说明应该携带什么信息、数据库如何裁决,以及哪些返回和持久结果能证明机制生效。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[09 · 并发更新:版本条件与行锁](../?module=api-database&lesson=09);[10 · 幂等键与唯一约束](../?module=api-database&lesson=10)
+
+#### Q6 通知已经被接收,为什么还可能再发一次?
+
+商品与待发事件同事务写入后,发布器发送成功,但本地确认没记上。请推演下一轮会发生什么,说明领取租约、确认 token、接收方 event_id 分别保护哪一段,以及最终应查哪些状态。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 幂等键与唯一约束](../?module=api-database&lesson=10);[11 · 事务 Outbox 与跨服务一致性](../?module=api-database&lesson=11)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 给一件商品做双端验收,再复现旧页面冲突
+
+在本机已完成迁移的练习环境中,使用新标记创建一件 2590 分旅行收纳袋。独立从 HTTP 与 SQL 两边核对,模拟两个客户端读到同一版本,再完成一次成功修改、一次旧版本冲突和带版本删除。先写出每一步的预期,操作后再对照要点。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · ORM CRUD 与输入输出模型](../?module=api-database&lesson=03);[09 · 并发更新:版本条件与行锁](../?module=api-database&lesson=09)
+
+#### C2 重试与通知去重能在真实提交下成立吗?
+
+只在独立测试库安排一次同键并发创建,再演练通知接收成功但本地确认丢失。先说明为什么不能把所有并发任务绑定到同一个回滚 fixture,再写出数据核对和恢复计划。独立完成后记录实际结果;未执行的故障分支不标通过。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[11 · 事务 Outbox 与跨服务一致性](../?module=api-database&lesson=11);[12 · 真实MySQL隔离测试与故障验收](../?module=api-database&lesson=12)
+
+### 掌握标准与证据
+
+- [ ] 我能独立解释请求、Session、flush、commit 与公开响应之间的边界。
+
+  证据:不看参考要点画出一次创建及第二次写入失败的流程,标出已执行、未提交、回滚和可返回成功的位置,并用一组实测记录对应。
+
+- [ ] 我能独立对齐表、ORM 和输入输出规则,完成带版本的商品操作。
+
+  证据:交出真实 id 的 HTTP/SQL 双端结果、UTC 与价格检查,以及一次旧版本冲突和按当前版本删除的完整证据。
+
+- [ ] 我能独立验证查询规模、连接预算和迁移后的真实结构。
+
+  证据:交出 SQL 次数或分页边界记录、包含多进程及发布重叠的连接预算、迁移状态与实际结构对照,不能只交配置值或生成的迁移文件。
+
+- [ ] 我能独立判断提交未知、重复创建与重复通知,并选择合适的验收隔离。
+
+  证据:在独立测试库完成一组真实提交故障实验,核对幂等结果、事件去重、连接释放与恢复请求;明确说明保存点测试不能证明的部分。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 为什么能共享 Engine,却不能共享 Session?
+
+- /live 只证明进程能回应,/ready 还要实际借连接并查询数据库;先核对配置、数据库可达性与借用情况,不把存活结果当作数据链路已准备好。
+- 每进程复用 Engine 池,每请求或独立任务建立自己的 Session;Session 含事务状态,并发分支不能共享,物理连接之后可被池重复借用。
+- 例中稳态应用上限为 30,发布新旧完全重叠可到 60;还要加入任务消费者、迁移和诊断连接并留余量,不能只拿单池五条和数据库总限额比较。
+- 分别观察池等待、建连接、行锁与 SELECT 的预算;请求结束后核对活动事务和后续请求,Sleep 连接可能只是空闲复用,pre_ping 或 recycle 不会恢复正在执行的失败事务。
+
+容易误判:把“复用数据库资源”理解成所有任务共用一个 Session,或将每进程容量当作全服务容量。前者混用事务状态,后者会在扩进程或发布重叠时低估连接量。
+
+#### Q2 add、flush、commit 与返回 201 是同一件事吗?
+
+- SQL 真实表、ORM 映射与 Pydantic 输入输出要对齐字段、缺值和价格边界;id 与创建时间由服务/数据库产生,不能把任意输入直接当作公开输出。
+- add 登记对象,flush 才发送 INSERT 并可取得生成 id,refresh 用来显式读取数据库生成值;拿到 id 不证明事务已提交。
+- 在明确事务内形成输出数据,成功离开事务边界后才返回成功;辅助函数不偷偷提交,失败时让异常触发整笔回滚,不能在响应之后隐藏提交结果。
+- PATCH 省略价格表示保留原值,显式 null 按本课契约拒绝;DATETIME(6) 存储值依既定 UTC 解释,响应带明确 UTC 时区,updated_at 要由写路径维护。
+
+容易误判:把 flush 当 commit,或把缺字段与空值统一填成默认值。前者会在尚未持久化时宣称成功,后者可能把本来要求保留的价格清空或重置。
+
+#### Q3 商品分页了,评价读取就一定有上限吗?
+
+- 逐商品读取未显式加载的评价会形成 1+N 次往返,异步环境的隐式读取还可能报错;先记录实际 SQL 次数,明确加载后再访问关系。
+- selectinload 可以将受控商品集合的关系读取合并,但限制商品数量不等于限制评价总数;真实接口按商品单独分页评价,区分无商品的 404 与无评价的空列表。
+- 商品列表使用创建时间与 id 的稳定复合倒序,下一页带上上一页末尾游标;保留相同条件并用实际计划确认访问范围,游标非法时在构建查询前拒绝。
+- 评价列表按评价 id 有界继续取,最多额外读取一条判断下一页;游标编码不是授权或加密,各分页请求也不是一个跨请求一致快照。
+
+容易误判:把减少查询次数等同于限制返回规模,或认为用了 Base64 游标就安全且全程快照一致。批量加载、分页边界、权限和事务范围是不同约定。
+
+#### Q4 迁移版本号没前进,表一定没变吗?
+
+- 模型是目标结构,revision 是演进步骤,autogenerate 只给待审查差异;先核对实际表结构和已执行动作,版本号未变不保证结构完全未变。
+- MySQL 结构操作有隐式提交边界,单条原子 DDL 不代表整个多语句迁移能被 ROLLBACK 撤销;普通 DML 保存点测试也不应承担迁移回滚验证。
+- 原 shop_lab 可能已被数据库模块加入 version、索引和约束,所以本课使用独立 shop_lab_migration 建立基线,保留原库,不对已有状态盲目 stamp 或重复加列。
+- 先做与旧代码兼容的扩展,再按主键有界回填并核对剩余量,最后收紧约束与模型;每阶段都审核迁移、查看实际结构并验证旧新代码可用,不宣称所有 DDL 无锁。
+
+容易误判:只看迁移版本表判断是否可以无脑重跑,或认为外层事务能兜住所有 DDL。结构可能部分改变,重复执行和错误测试隔离都会掩盖真实风险。
+
+#### Q5 旧版本冲突与响应丢失重试,为什么需要两种机制?
+
+- 并发编辑把读取到的旧版本作为条件,与字段更新和版本递增放在同一条原子 UPDATE;第一次成功后旧版本请求得到冲突,不通过先 SELECT 再无条件写入实现。
+- 所有修改和删除路径都遵守已启用的版本协议,旧入口或不递增版本的脚本会破坏保护;本课最终删除同样要求当前版本。
+- 一次逻辑创建重试保持同一个 Idempotency-Key,由可信操作作用域和唯一键约束串行化争用,保存状态与响应供重放;网络错误本身无法判断是否已提交。
+- 同键同输入应返回首次结果与相同 id,同键不同输入应冲突;新键意味着新的逻辑创建。幂等表、商品与结果保存在同一事务,不能只在内存记一份键。
+
+容易误判:把 version 当创建去重键,或丢响应后每次换新幂等键。版本解决旧编辑覆盖,幂等键解决同次创建重复;混用会漏掉各自需要保护的窗口。
+
+#### Q6 通知已经被接收,为什么还可能再发一次?
+
+- 商品与 outbox 同一事务保存,避免商品提交后通知记录漏写;发布器在短事务领取并提交后再发 HTTP,不在持锁事务中等待外部服务。
+- 发送成功而本地确认丢失时,接收方可能已完成,待发事件仍可在租约到期后重领并重复发送,因此本课是至少一次交付而非自动恰好一次。
+- 领取 token 防止旧执行者改写不属于自己的本地确认状态,但不能阻止旧执行者继续发网络请求;接收方使用 event_id 与自身事务去重。
+- 核对商品与事件只产生一次、接收表同 event_id 仍一行、发送确认最终落下,并观察尝试次数与积压。幂等创建原键重放也不应再新增 outbox 事件。
+
+容易误判:以为有租约和 token 就不会重复发送,或把 HTTP 返回成功当作本地 delivered_at 已写入。网络发送、接收提交和本地确认是不同动作,需要分别核对。
+
+#### C1 给一件商品做双端验收,再复现旧页面冲突
+
+- 先核对当前配置、库名、迁移状态和 /ready,用响应真实 id 定位数据;HTTP 与 SQL 都能确认名称、整数价格、上架状态以及按 UTC 约定的时间。
+- 两个客户端分别保存同一版本快照;A 修改后读取新值和新版本,B 携旧版本被拒绝,SQL 中仍为 A 的结果且版本只增加一次。
+- 加入一次非法价格或显式 null 的 PATCH,记录输入拒绝及原记录未被改写;说明省略字段与 null 的不同,不借冲突操作覆盖正确数据。
+- 用当前版本删除得到 204 空体,再核对实际记录不存在;保留整个请求顺序、状态与数据库结果,解释本模块 PATCH 和版本删除不能照搬基础 API 的无版本 PUT 流程。
+
+容易误判:只在接口显示冲突就结束,或用错误版本清理失败后直接手工删数据掩盖结果。需要核对未覆盖的持久状态,并遵守最终写入协议完成后续操作。
+
+#### C2 重试与通知去重能在真实提交下成立吗?
+
+- 先核对测试 URL、库名和权限,运行已有迁移;并发任务各用自己的 Session 与连接,需要真实提交的部分使用相应独立 fixture,不能用共享外层事务证明跨连接可见性。
+- 同一随机幂等键与同输入并发创建,响应状态和内容相同;按随机标记核对只产生一件商品、一份结果和一条 outbox,不只比较两次 HTTP 响应。
+- 发送到本机去重接收端后按原课跳过一次本地确认,等待租约条件满足再恢复发布;接收表仍只有该 event_id 一行,待发记录最终确认,解释为什么重复发送是允许出现的。
+- 记录故障前、中、后的 HTTP、数据库、连接释放与后续恢复请求;清理仅针对本次随机键相关数据,并区分自动断言、手工记录与未验证项。
+
+容易误判:在同一连接内看见相同结果,就声称并发唯一性和真实提交已经验证。共享事务、保存点与真实独立连接的可见性不同,恰好避开竞争不能证明故障处理正确。
+
+---
+
+## 04 异步任务:我真的掌握了吗
+
+围绕商品清单导出,检查自己能否区分受理、执行与完成,并用数据库、日志和文件证据判断重复、等待和恢复。
+
+### 如何自测
+
+运营提交一份商品清单,页面已经返回任务编号,但稍后仍查不到文件。先不打开参考要点,沿着你认为必要的证据说明任务可能停在哪一段,再完成下面的判断与变式。
+
+- 已完成异步任务模块,能找到最终 tasks_lab 的状态存储、worker、publisher、recovery 和终态验证文件;第 01 课内存实验与第 11 课后的持久实现要分开判断。
+- 每题先写自己的结论、理由与验证办法,再展开要点。需要操作时只用原课本机专用实例和新的 job_id,不清空队列、任务表或其他任务的输出。
+- 将“尚未验证”“借助提示完成”和“能独立完成”分开记录;熟悉答案不能替代自己得到的状态、日志与文件证据。
+
+本轮目标:用导出链路说明和有证据的变式记录,找出执行位置、交付窗口、容量与恢复方面仍需回看的课时。
+
+原模块讲义:[tasks-lessons.md](./tasks-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 拿到 202,究竟确认了什么?
+
+同学在第 01 课拿到 202 就显示“文件已完成”;另一位给第 03 课后台函数加上 await,认为这样就能跨服务重启恢复。分别判断两种说法,并说明提交响应、后续查询和重启实验应该怎样核对。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · 提交导出任务,返回 202 与可查询状态](../?module=tasks&lesson=01);[03 · 区分 BackgroundTasks 与 create_task 的生命周期](../?module=tasks&lesson=03)
+
+#### Q2 限了并发,为什么仍会堆积或卡住?
+
+商品读取最多允许两个并发。同学一次创建大量 Task,再给读取加 Semaphore;另一份线程版在编号 7 失败时跳过 task_done。请分别预测排队与退出行为,给出只用 20 个编号即可执行的验证方案。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 用 TaskGroup、队列与超时约束 asyncio](../?module=tasks&lesson=02);[04 · 用 Lock 与 Queue 管理线程共享状态](../?module=tasks&lesson=04)
+
+#### Q3 等待超时以后,还剩谁在运行?
+
+导出的一段调用同步读取库,另一段只做纯 Python 文字计算。为它们选择执行方式,并解释为什么调用者超时了,仍不能宣布所有后台工作已停止。你会记录什么来判断进程池是否值得使用?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · 在线程池中调用阻塞 I/O](../?module=tasks&lesson=05);[06 · 用进程池处理 CPU 密集导出](../?module=tasks&lesson=06)
+
+#### Q4 同一导出出现两条消息,是失败吗?
+
+同一 job_id 和固定商品快照被两条不同消息送到 worker。第一次已写好文件,却在确认消息前退出。解释再次执行是否符合设计,以及怎样判断重复没有形成冲突的业务结果。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[08 · 启动 Celery broker、worker 与结果缓存](../?module=tasks&lesson=08);[09 · 理解 ack、重投与幂等输出](../?module=tasks&lesson=09)
+
+#### Q5 次数还没用完,为什么不能再试?
+
+一个任务还有重试额度,但总截止时间已过;另一个收到重复消息,next_attempt_at 却仍在未来。分别判断能否读取或领取,并区分暂时错误、永久错误与实际执行次数。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 为重试设置退避、抖动与总预算](../?module=tasks&lesson=10);[11 · 用 MySQL 保存任务状态与租约](../?module=tasks&lesson=11)
+
+#### Q6 任务已落库,消息却没到怎么办?
+
+导出记录已提交时 broker 不可用,后来又发生发送成功但 sent_at 没写回。请画出不用代码的状态顺序,说明 outbox 如何补遗漏,以及为何 worker 仍需领取与完成条件。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[12 · 用事务 outbox 连接状态提交与消息发布](../?module=tasks&lesson=12);[15 · 实现优雅退出、取消与失败隔离恢复](../?module=tasks&lesson=15)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 短清单排队与周期重复同时出现
+
+有一份两件商品的短清单和一份较长清单,又开启了每分钟导出。先独立设计两组小实验:判断队列拆分与容量是否有效;区分同分钟重复与跨分钟重投。只用本机专用任务,写出能够支持和推翻判断的证据。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[13 · 限制受理量并拆分长短任务队列](../?module=tasks&lesson=13);[14 · 用持久时间槽合并周期执行,并验证跨分钟重投边界](../?module=tasks&lesson=14)
+
+#### C2 一个旧执行者晚回来了
+
+先写计划,再在原课专用环境验证:A 持有租约后失效,扫描恢复并等待退避后由 B 接管,A 又晚到写回。追加取消或次数耗尽的一条分支。要保留什么,才有依据记为“能独立完成”?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[15 · 实现优雅退出、取消与失败隔离恢复](../?module=tasks&lesson=15);[16 · 完成故障注入与可观测性验收](../?module=tasks&lesson=16)
+
+### 掌握标准与证据
+
+- [ ] 我能独立解释受理、消息交付、工作执行与业务完成为何不是同一件事。
+
+  证据:不看答案画出一次导出的状态顺序,标出两个失败窗口,并说明每阶段该查哪份记录。
+
+- [ ] 我能独立选择并约束协程、线程或进程,并完成一项小导出。
+
+  证据:自行准备输入和执行、排队上限,保存正常结果、一条失败分支和实际退出证据。
+
+- [ ] 我能独立证明重复交付没有改变本次有效结果。
+
+  证据:用新 job_id 得到重复消息记录、唯一有效 result_ref 和与固定快照一致的文件,并解释消息数与 attempts 的区别。
+
+- [ ] 我能独立判断任务在等待、失败、失去租约或已终止,并选择对应恢复动作。
+
+  证据:为一次故障留下状态、当前 token、期限、恢复耗时和最终结果记录,同时明确尚未实测的分支。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 拿到 202,究竟确认了什么?
+
+- 202 表示受理,响应提供 job_id、当前状态与查询地址;文件完成要通过后续状态和结果确认。第 01 课尚未接执行器,任务保持 queued 是正常现象。
+- 第 03 课后台函数能推进状态,却仍属于应用进程;添加 await 改变等待方式,不会把任务持久保存。
+- 正常关闭可能给任务清理时间,强制退出不保证执行 finally。重启后不能假定内存记录与未完成工作会恢复。
+- 保存真实编号,先查状态、完成后查结果,再在本机重启对应实验进程查询原编号;分别记录终态保护和记录消失,不能把响应发出当作完成证据。
+
+容易误判:把“HTTP 已返回”“后台任务已登记”和“业务已完成”当成同一件事,会提前承诺结果,也会遗漏进程退出后的工作丢失。
+
+#### Q2 限了并发,为什么仍会堆积或卡住?
+
+- Semaphore 只限制进入读取区的并发,不限制已创建的 Task 和等待项;有限队列与固定消费者才能同时约束排队量及执行者数量。
+- 每次取出任务都应结清,包括失败项与结束哨兵;遗漏 task_done 会让队列 join 一直等待未归零的计数。
+- 共享成功/失败计数放在短锁区,耗时工作放在锁外;队列计数清零与所有线程退出还需分别检查,不能靠队列暂时为空判断完成。
+- 记录真实读取峰值不超过 2。线程版消费完全部 20 项时核对成功数加失败数为 20,并确认线程退出;协程 TaskGroup 的失败或超时分支会取消其他任务,应核对消费者取消清理、没有误报完整成功,并把未执行项单独记录,不当作已失败的任务凑总数。
+
+容易误判:“加并发限制就不会排队”混淆执行与提交;“失败项不用结清”会让协调者永远等不到正确的完成计数。
+
+#### Q3 等待超时以后,还剩谁在运行?
+
+- 同步阻塞 I/O 可交给受限线程池,并限制提交数量;纯 Python 计算可用小规模进程池比较,不能因为业务叫“异步任务”就把阻塞工作直接放进事件循环。
+- Future 等待超时只结束调用者的等待;已运行线程不能靠 cancel 安全强杀。同步库的超时及分段检查 Event 才构成协作停止边界。
+- 进程函数应在可导入模块顶层,传可序列化数据,并保留显式 spawn 与入口保护;进程结果等待超时也不证明 CPU 函数已经结束。
+- 保持输入与计时范围一致,先核对串行与进程结果相同,再记录耗时;小任务可能因启动和传输更慢。退出还需工作函数结束和执行器收尾证据。
+
+容易误判:“TimeoutError 就是函数被终止”会遗漏仍运行的工作;“进程越多一定越快”忽略任务规模、启动成本和资源限制。
+
+#### Q4 同一导出出现两条消息,是失败吗?
+
+- 提交、转交、执行与确认是不同阶段;完成后确认仍存在“副作用已发生但确认未送达”的窗口,因此重投不自动说明系统失效。
+- 第 09 课的相同业务编号与不可变快照应生成相同目标路径和内容;比较两次路径、摘要和完整 JSON,而不是只看消息缓存的 SUCCESS。
+- 临时文件后原子替换避免半文件;同一 job_id 如果携带不同内容,它不能决定哪个版本正确,也不能证明跨机器或断电的一致性。
+- 消息 ID 区分交付,job_id 区分业务导出;相同 task_id 本身不保证去重。说明 broker 配置和恢复前提,不把 late ack 或原子替换称为恰好一次。
+
+容易误判:把重复交付一概当成业务失败,会绕开恢复机制;把相同消息编号当作去重锁,也无法约束实际函数执行或输出版本。
+
+#### Q5 次数还没用完,为什么不能再试?
+
+- 尝试必须同时满足次数与总时间条件;过了 deadline 应在真实读取前结束,不能因还有次数就重新获得一整份时间预算。
+- 只对已知且有机会恢复的暂时错误设置有限重试,永久输入错误直接失败;退避与抖动分散压力,不能替代单次 I/O 超时。
+- 持久领取需检查状态、取消、截止时间、次数和 next_attempt_at;未来时间未到,重复消息也不能领取,更不能提前增加 attempts。
+- 消息重新交付次数不等于实际业务执行次数。用过期预算、永久错误及未到领取时间的样本分别记录真实调用和领取是否发生,而非只数日志行。
+
+容易误判:“剩余次数必须用完”让过期工作继续耗资源;只延迟某条消息而不检查持久时间,会被另一条重复消息提前触发。
+
+#### Q6 任务已落库,消息却没到怎么办?
+
+- 任务和待发事件在同一 MySQL 事务内保存,任一步失败一起回滚;提交端在持久保存后才表示受理,不把发送网络消息放进这个事务。
+- publisher 短事务领取后先提交,再发消息,最后按当前 token 与有效租约确认。broker 不可用时保留未发送事件,等待有预算的恢复。
+- 发送成功却没确认会重复发送;outbox 保存发送意图,并不保证恰好一次。worker 仍按状态、token、租约、deadline 和取消条件决定是否执行及写回。
+- 派发确认也不等于 worker 已领取。长期 queued 的恢复需要检查时间和已有 pending 事件,避免重复堆通知;重新派发不能直接算一次实际执行。
+
+容易误判:“有 outbox 就能删掉幂等和恢复检查”混淆保存发送意图、重复交付与业务结果写回三种不同问题。
+
+#### C1 短清单排队与周期重复同时出现
+
+- 将 queue_name 随 outbox 保存,确认 fast/bulk 真有对应且受限的消费者;查看实际路由和等待,不能只改队列名字就宣称隔离了执行容量。
+- 先统计已有 queued/running,所有提交路径使用相同容量门闩和额度;满额拒绝新增并给可重试的 503,不能返回不存在任务的 202。
+- 生成新的实验时间槽,同槽重复只创建一个 job 及事件;另一个明确执行分钟可产生新槽,当前模型因此不保证原始信号的恰好一次。
+- 保存受理结果、队列、任务编号、槽记录和等待年龄,恢复消费者后核对结果;调度器停机的分钟不会自动补跑,不把未实现能力写成结论。
+
+容易误判:“拆队列等于自动隔离资源”和“同槽唯一等于任何重投都只执行一次”分别忽略消费者容量和时间槽采用的时间语义。
+
+#### C2 一个旧执行者晚回来了
+
+- 只操作明确的新 job_id,记录 A 的 token、attempts 和期限;过期后由扫描转换,确认持久退避,到期才让 B 领取,重复消息不能提前增加次数。
+- 旧 token 完成应失败,B 生成独立真实文件后才能写入有效 result_ref;数据库引用的文件必须等于固定快照,不能仅看 Celery 状态。
+- 取消分支不能发布成功;次数或期限耗尽需保留失败隔离记录,不清零旧任务伪造恢复。修复重放使用带 replay_of 的新任务。
+- 保存阶段日志、业务与消息编号、状态变化、实际恢复耗时和文件验证;未做分支标未验证,超预算要说明等待来自租约、扫描、退避还是队列。
+
+容易误判:“A 曾拿到锁就仍有资格完成”忽略当前权属;“看懂恢复流程”也不能替代旧令牌被拒绝和结果文件一致的实际观察。
+
+---
+
+## 05 Redis 缓存:我真的掌握了吗
+
+从同一商品的两次读取出发,检查自己能否解释命中、旧值、期限与故障,并守住 MySQL 的读取容量。
+
+### 如何自测
+
+商品在 MySQL 中已经改名,页面偶尔仍显示旧名。同学建议把 TTL 设得很长,并在 Redis 故障时让所有请求直查数据库。先独立判断这些做法改变了什么,再完成下面的自测。
+
+- 已完成 Redis 模块,能找到原课读取、失效、单飞、降级与探针文件,知道本机商品样本及各专用 Redis 实例的用途。
+- 每题先写时序、判断和取证办法,再展开要点。操作时复用受限配置与真实 PRODUCT_ID,只改本次键或原课明确的专用容器。
+- 正常、故障和未执行项目分别记录;自评帮助定位薄弱点,不因看完答案或勾选条目自动证明掌握。
+
+本轮目标:用来源、版本、期限、回源次数和恢复证据,判断自己能否独立维护一条可解释且容量受控的商品缓存读路径。
+
+原模块讲义:[redis-lessons.md](./redis-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 第二次更快,是否证明缓存正确?
+
+连续读同一商品,第一次来源 database,第二次 cache。有人只覆盖缓存值,TTL 随后为 -1;另一人把缓存 schema 当成商品 version 一起递增。还需验证什么?这两个修改分别有什么问题?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · 连接 Redis、选择类型并设置 TTL](../?module=redis&lesson=01);[02 · 实现 Cache-Aside 与带版本的 JSON 缓存](../?module=redis&lesson=02)
+
+#### Q2 提交后删缓存,旧值为什么还能回来?
+
+A 已从 MySQL 拿到旧商品却尚未回填。B 提交新名称并删缓存,随后 A 才填入旧值。请按顺序写出缓存和数据库版本,再说明提交后失效解决了什么、没有解决什么。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · 提交后失效缓存,并复现旧值回填竞态](../?module=redis&lesson=03)
+
+#### Q3 “没有商品”和“数据库没连上”能一样缓存吗?
+
+连续查询合法但不存在的商品编号时,你想减少回源。一次请求却遇到 MySQL 连接错误,稍后同编号商品又可能被创建。分别设计返回含义、短期缓存与写入失效,并说明非法编号应在哪里被拒绝。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[04 · 阻止缓存穿透:输入校验与短期负缓存](../?module=redis&lesson=04)
+
+#### Q4 锁过期了,原填充者还能删锁吗?
+
+A 取得热门商品填充权后变慢,锁过期,B 成为新持有者。A 恢复后准备写缓存并删锁。请说明应该如何判断这两个动作,再设计不靠随机调度的验证。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · 抑制热点击穿:带令牌的单飞回填](../?module=redis&lesson=05)
+
+#### Q5 Redis 停了,全部直查 MySQL 就能保住接口吗?
+
+先遇到一批商品缓存同时到期,随后模拟整个 Redis 停止。分别说明 TTL 抖动、少量预热和有界回源能解决哪部分,并写出数据库回源名额耗尽时应该出现什么结果。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · 应对缓存雪崩:TTL 抖动、受控预热与降级](../?module=redis&lesson=06);[09 · 配置连接池、超时与熔断并记录缓存指标](../?module=redis&lesson=09)
+
+#### Q6 少发几次网络请求,等于中间不能被修改吗?
+
+商品查询限流需要增加计数,并在首次计数时设期限。同学建议不带事务的 Pipeline,还说网络中断后无限重试即可。比较 Pipeline、WATCH 与本课 Lua 的职责,并给出四次请求的预期观察。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[07 · 使用 Pipeline、WATCH 与 Lua 保证正确的原子操作](../?module=redis&lesson=07)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 容量和故障延迟一起变差
+
+先独立写观测计划,再用原课专用实例验证:容量实例发生淘汰;商品缓存调用连续失败后被短暂绕过。报告必须区分淘汰、过期、正常未命中、连接错误和 bypass,并解释为何不能把 broker 搬进缓存实例的另一个 DB 来省资源。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[08 · 控制缓存容量与淘汰,隔离任务 Broker](../?module=redis&lesson=08);[09 · 配置连接池、超时与熔断并记录缓存指标](../?module=redis&lesson=09)
+
+#### C2 重启后读得到,就证明不会丢了吗?
+
+先写下三组实验的判断,再只用原课专用容器验证:无持久化缓存重启、独立 AOF 实例正常重启、主节点写入后副本读取。说明每组能证明什么,以及仍不能作出什么承诺。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 演练重启、复制与持久化恢复](../?module=redis&lesson=10)
+
+### 掌握标准与证据
+
+- [ ] 我能独立解释商品缓存从缺失、回源、命中到失效的过程。
+
+  证据:不看答案画出正常读取与旧值晚回填两条时序,并说明 schema、商品 version 和 TTL 各表达什么事实。
+
+- [ ] 我能独立建立有格式验证、期限和明确缺失含义的读取路径。
+
+  证据:在新终端恢复配置,用真实样本展示 database/cache 对照、坏格式修复与负缓存读取,留下实际输出。
+
+- [ ] 我能独立验证减少回源的机制,而不只是觉得请求变快。
+
+  证据:交出有限并发下真实 loader 次数、旧 token 释放失败、回源上限及超出等待预算的可见结果。
+
+- [ ] 我能独立区分缓存容量、连接故障、主动绕过和恢复边界。
+
+  证据:记录命中率、淘汰增量、状态与延迟,并用重启或副本实验说明哪些数据来源及恢复能力已被验证。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 第二次更快,是否证明缓存正确?
+
+- 命中只说明找到键,还要核对真实商品编号、两次内容、整数分价格和 UTC 时间等约定字段;坏 JSON 或不支持的结构应回源修复。
+- schema 表示缓存编码结构,商品 version 表示业务数据版本;两者不能替代,商品改名不自动意味着编码格式改变。
+- TTL 为 -1 表示存在但无期限,-2 才是键不存在。普通 SET 覆盖可能清掉原期限,不能假定第一次设置的期限会一直保留。
+- 只对真实样本的明确键做实验,记录两次来源、内容对比与剩余期限;再注入坏结构,确认来源回到 database 并写入合法副本。
+
+容易误判:“读得快就是读得对”忽略内容与期限;把 schema 当业务版本,也无法说明这份商品是否比数据库旧。
+
+#### Q2 提交后删缓存,旧值为什么还能回来?
+
+- A 回填后缓存可命中旧版本,而 MySQL 已为更高版本;课程通过手工安排顺序就能稳定复现,不必依赖碰巧的线程调度。
+- 先提交再失效可避免新值尚未提交时读者回源旧值,但不能阻止已经拿到旧快照的请求晚到回填。
+- 修改先用版本条件和受影响行数核对,再返回已提交的真实结果;提交应答未知不能盲目更换条件重试。
+- 失效失败需保留可恢复工作,可按课程范围以持久事件补偿,并明确详情及派生键范围。需要强一致读时显式绕过缓存,不能由一次 DEL 或短 TTL 承诺强一致。
+
+容易误判:“提交后删过一次,之后绝不会读旧值”漏掉在途旧读取;吞掉失效失败还会让更新副本的工作无法追踪或恢复。
+
+#### Q3 “没有商品”和“数据库没连上”能一样缓存吗?
+
+- 只有数据库成功查询且确定无行,才能缓存明确 found=False 的缺失结论;连接失败不代表不存在,不能存成同一种空结果。
+- 正负结果需可区分的编码,负缓存用课程中更短的期限,并与普通详情缓存使用清楚的命名空间,避免错误解码。
+- 创建、更新和删除提交后都要考虑详情及 lookup 键;漏掉新建编号的负缓存,会让后来真实商品继续被旧缺失结论挡住。
+- 编号先做严格类型及范围检查,拒绝 0、负数、字符串、True 和超出 BIGINT 正范围的值;这些输入不应先访问 Redis 或 MySQL。
+
+容易误判:“没拿到商品就缓存空值”混淆无记录与依赖故障,既隐藏故障,又可能让真实商品被误判不存在。
+
+#### Q4 锁过期了,原填充者还能删锁吗?
+
+- 单飞合并同一时段的重复填充;取得权利后还要再读缓存,避免别人已填好。没有取得权利的读者只作有限等待。
+- 过期后可能出现新持有者,A 必须比较自己的 token 与当前值;旧 token 不得删除 B 的锁,也不得在失去权属时发布缓存。
+- 比较与删除或发布应在短 Lua 脚本中连续执行,防止检查后到修改前所有权改变;普通先 GET 再 DEL 不能保证这个组合。
+- 用专用键先写 old-owner 再写 new-owner,验证旧 token 释放返回 0 且新锁仍在;另以固定小并发和计数 loader 核对真实回源次数,超时保留为失败证据。
+
+容易误判:“我曾拿到锁,所以最后必须由我释放”把过去权属当成当前权属;直接 DEL 可能删掉后来者的锁,造成更多并发回源。
+
+#### Q5 Redis 停了,全部直查 MySQL 就能保住接口吗?
+
+- TTL 抖动错开一批键的正常过期,减少集中回源;它不能阻止整个 Redis 不可用,也不能证明返回内容一定足够新。
+- 预热只处理有限热点或课程中的最近少量商品,限制数量和节奏,释放取编号的数据库事务后再逐件填充,不制造无界全库压力。
+- Redis GET/SET 失败可绕过,但回源仍需本进程并发名额与短等待;多进程总预算要相加,不能只看一个 Semaphore 数字。
+- 拿不到名额应出现 SourceBusy 等受控容量失败,由接口明确响应;MySQL 本身错误仍传播。恢复 Redis 后验证来源逐步由 database 转为 cache。
+
+容易误判:“降级就是取消限制、全查真源”可能把缓存故障扩大为数据库故障;高命中率也不能证明故障期间有足够容量。
+
+#### Q6 少发几次网络请求,等于中间不能被修改吗?
+
+- Pipeline 减少往返且按命令顺序返回,但 transaction=False 不让整批变成原子操作;网络错误不能证明所有命令都没执行。
+- WATCH 检查键是否在提交前被其他操作改动;冲突后重新读取再计算,并限制尝试次数,不能拿旧计算结果继续提交。
+- 本课短 Lua 先检查参数,再组合 INCR 与首次 PEXPIRE,避免计数成功却漏设期限;脚本不做长循环或外部请求。
+- 固定窗口中前三次允许、第四次拒绝且期限仍为正,到期后允许;这不是精确滑动窗口。超时重试可能重复计数,需承认保守限流边界,不能无限重试来猜结果。
+
+容易误判:“合并发送就是原子”和“客户端异常就是服务端没执行”混淆网络效率与执行语义,可能留下永久计数器或重复计数。
+
+#### C1 容量和故障延迟一起变差
+
+- 只在容量实例有限写入,记录前后 evicted_keys 增量、used_memory、RSS 与 maxmemory,再读取明确样本键计算本批命中率;不把累计值直接当作本次结果。
+- 键可在 TTL 未到时因容量压力被淘汰;自然到期与淘汰分别记录。maxmemory 不等于 Redis 进程或容器全部内存占用。
+- 先以固定故障函数验证三次真实错误后第四次 bypass,冷却探测后恢复;记录实际调用次数、状态和延迟。正常返回 None 是 miss,认证错误也不能用无限探测掩盖。
+- broker 与可重建缓存需要独立容量和可靠性安排,DB 编号不隔离内存、CPU 或故障;记录写入拒绝如何暴露,尚未执行的项目明确标出。
+
+容易误判:把所有 None 都计作未命中,会隐藏绕过与错误;另选一个 DB 也保护不了消息免受同实例的容量或故障影响。
+
+#### C2 重启后读得到,就证明不会丢了吗?
+
+- 无持久化缓存的键可能消失,MySQL 商品应仍完整,首次回到 database、随后 cache;证明的是副本可重建,不是 Redis 保存了真源。
+- AOF 样本仅首次写入,重启后只读值和剩余 TTL;再次写会掩盖恢复失败。正常重启成功不等于已经验证断电或所有磁盘故障。
+- 主节点写入后在同一连接按课程方式 WAIT,观察副本确认再读取;确认复制不等于磁盘持久化或强一致承诺。
+- 停止专用主节点,核对副本链路 down 且不会自动升主,再恢复并检查。保留值、期限、角色、确认数及时间;不能用缓存实验代替任务 broker 的持久状态与交付恢复验收。
+
+容易误判:“正常重启保住样本,所以任何故障都不丢且副本自动接管”混淆了持久化、复制与故障切换。
+
+---
+
+## 06 Docker 容器化:我真的掌握了吗
+
+检查自己是否能从进程、网络、存储和发布证据出发,独立把商品服务与导出任务放入容器,并判断什么才算可恢复。
+
+### 如何自测
+
+商品 API 镜像已构建成功,容器显示 running,但商品读取失败,重建后文件还可能消失。先不看要点,用自己的顺序区分镜像内容、进程、连接与持久数据,再完成发布变式。
+
+- 已完成 Docker 模块,知道本机 Compose 项目、镜像、秘密文件与命名卷的位置;操作前先核对 Docker context 为专用练习环境。
+- 先独立回答,再对照要点。实际演练只用原课有界样本和独立恢复容器,不删除业务卷,不把生产 context 当成实验环境。
+- 记录真实镜像标识、状态、日志与恢复结果;未推送、未注入或未恢复的项目写未验证,不用“容器能运行”代替掌握证据。
+
+本轮目标:形成能解释启动失败、验证数据保留并说明兼容回退边界的本机交付记录,据证据确定需要复习的部分。
+
+原模块讲义:[docker-lessons.md](./docker-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 同一镜像,重启与重建有什么不同?
+
+主进程执行完后容器停止,同学想在外面加无限等待来“常驻服务”。另一位把导出文件写进容器,认为以后重新 run 同一镜像仍能找到。分别判断,并设计两个最小观察。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · 镜像、容器与进程生命周期](../?module=docker&lesson=01);[05 · MySQL 持久卷与可恢复备份](../?module=docker&lesson=05)
+
+#### Q2 依赖层命中缓存,能直接发布吗?
+
+只改一行商品 API 源码后,依赖层显示 CACHED。同学说依赖一定最新,基础镜像标签相同也代表两次产物完全一样。你仍需记录和验证哪些输入?为什么还要控制复制范围?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 为 FastAPI 编写非 Root Dockerfile](../?module=docker&lesson=02);[03 · 分层缓存、多阶段构建与可重复输入](../?module=docker&lesson=03)
+
+#### Q3 宿主能连数据库,为什么 API 容器不能?
+
+宿主在 127.0.0.1:3307 能查商品,API 容器使用相同地址却失败。有人提议公开更多数据库端口,并用 EXPOSE 代替 Compose 端口配置。请按请求所在位置解释地址、端口和验证顺序。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[04 · Compose 网络、服务 DNS 与端口](../?module=docker&lesson=04)
+
+#### Q4 卷还在、备份命令成功,够了吗?
+
+重建 MySQL 容器后专用标记仍可查询,备份也生成了非空文件。两份证据各能证明什么?请设计一个不覆盖原 shop_lab 的恢复检查。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · MySQL 持久卷与可恢复备份](../?module=docker&lesson=05)
+
+#### Q5 容器 running 后,还要等哪几道门?
+
+初次启动时 MySQL 已 running,但业务表没建立;运行一段时间后数据库又短暂停止。分别说明启动与运行阶段该观察什么,并判断每个 API 副本启动时都执行迁移是否合适。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · 健康检查、启动重试与一次性迁移](../?module=docker&lesson=06)
+
+#### Q6 换成非 root,就保护好秘密了吗?
+
+镜像使用 UID 10001,却把密码复制进去再在下一层删除;另一份部署挂载秘密文件,却没让应用读取。为什么都可能失败?你会怎样验证收紧权限后业务仍工作?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 为 FastAPI 编写非 Root Dockerfile](../?module=docker&lesson=02);[08 · 配置、Secrets 与容器运行权限](../?module=docker&lesson=08)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 把完整导出链路放进 Compose
+
+从原课最终任务工程出发,先画 API、fast/bulk worker、publisher、recovery 与存储的职责和连接,再独立做本机验证:暂停目标 worker 后提交专用导出,恢复它并核对结果。不要把早期快照演示当成最终任务实现。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[09 · API、Worker 与队列的 Compose 整合](../?module=docker&lesson=09);[07 · CPU、内存、进程数与优雅退出](../?module=docker&lesson=07)
+
+#### C2 新镜像有问题,怎样证明能退回?
+
+准备一次本机小版本发布,只改商品服务表现,不加新业务。先写发布记录与停止条件,再按原课隔离环境验证;若决定回退旧镜像,必须先确认哪些兼容性,最后交出什么证据?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 镜像版本、发布回滚与故障验收](../?module=docker&lesson=10);[03 · 分层缓存、多阶段构建与可重复输入](../?module=docker&lesson=03)
+
+### 掌握标准与证据
+
+- [ ] 我能独立说明容器化请求经过的进程、网络和存储边界。
+
+  证据:不看答案画出宿主、API、MySQL、Redis 连接位置,预测主进程退出、stop/start 与重建对临时文件和卷的不同影响。
+
+- [ ] 我能独立构建并运行包含应用与迁移文件的受限镜像。
+
+  证据:交出真实构建输入和镜像标识、实际运行用户、依赖及文件检查,以及不打印内容的秘密读取验证。
+
+- [ ] 我能独立判断启动成功与业务就绪,并验证商品和导出的完整链路。
+
+  证据:提供数据库健康、迁移退出、readiness、按 job_id 核对的终态与文件;失败时能指出未过哪一道门。
+
+- [ ] 我能独立验证恢复或兼容回退,而不只写操作计划。
+
+  证据:在独立恢复库查到原标记,或在新结构上运行已验证旧镜像完成业务探针;保留原卷,并记录实测恢复结果和未验证边界。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 同一镜像,重启与重建有什么不同?
+
+- 容器运行的是主进程,主进程退出时停止是正常生命周期;应用已不服务时加无限等待不会让 API 重新提供服务。
+- stop 后 start 复用原容器和可写层;删除后重新 run 创建另一容器,同一镜像不包含前一个容器运行时写入的临时文件。
+- 需要保留的数据放入明确持久存储,命名卷与容器生命周期不同;卷仍不是备份,不能由容器重建成功推断所有故障可恢复。
+- 记录一次性进程退出与日志,再用专用标记比较原容器 stop/start 和新容器同一路径;不要把旧卷挂回后的数据误认为可写层被保留。
+
+容易误判:“容器对象存在就代表应用在服务”和“镜像一样就包含上次写入”分别忽略主进程状态和运行时可写层。
+
+#### Q2 依赖层命中缓存,能直接发布吗?
+
+- 先复制依赖锁并安装,再复制源码,可在只改源码时复用依赖层;缓存加快构建,不主动更新依赖,也不保证输入是最新的。
+- 标签可能移动,真实基础 digest、源码版本、目标平台和完整依赖锁用于固定及追溯输入;这些稳定也不能单靠 Dockerfile 声称字节级完全一致。
+- 多阶段的 builder 与 runtime 保持基础和架构一致,复制必要环境与应用;运行时实际核对依赖导入、迁移文件和非 root 用户。
+- 明确构建上下文和 COPY 范围,由忽略文件排除本机秘密、备份和虚拟环境;后来删除误复制秘密不能抹去旧镜像层。
+
+容易误判:“CACHED 意味依赖已更新且安全”把复用当审查;“同标签就是同内容”让发布记录无法对应实际产物。
+
+#### Q3 宿主能连数据库,为什么 API 容器不能?
+
+- API 容器中的 127.0.0.1 指 API 自己;本课 Compose 内访问数据库使用 mysql:3306,Redis 使用 redis:6379。
+- 宿主映射 3307 供宿主访问,发布规则左右分别属于宿主和容器;同一 Compose 网络内互通不依赖发布数据库宿主端口。
+- EXPOSE 是镜像元信息,不自动开放宿主入口。为修复容器内地址而扩大暴露端口并非必要步骤,也不能修正错误的目标位置。
+- 从 API 的网络位置依次检查 DNS、目标 TCP 和 app_user 认证查询;宿主成功、容器 running 或 TCP 通畅均不能替代真实数据库查询。
+
+容易误判:“localhost 永远是我的电脑”忽略网络位置;增加公开端口不能修复 API 把自己当成数据库的问题。
+
+#### Q4 卷还在、备份命令成功,够了吗?
+
+- 标记保留说明这次正确复用了持久卷;它不能抵御所有磁盘故障、误删或数据写坏,所以卷不能替代备份。
+- 备份退出成功、文件非空只说明生成了转储;仍需检查导入成功、样本与表引擎符合预期,并验证恢复后查询可用。
+- 按原课建立独立恢复容器、卷和恢复库,导入后核对 volume_probe;业务表迁移后生成的新备份还要补业务验证,不能覆盖原库和原卷。
+- 普通复制运行中目录可能没有一致性,同宿主副本也不能覆盖宿主故障;记录恢复范围与实测耗时,不用每日转储自动承诺分钟级数据损失目标。
+
+容易误判:“持久卷等于备份”和“生成文件等于恢复可用”分别漏掉独立副本和恢复验证,可能在故障时才发现唯一真源被覆盖或转储不可用。
+
+#### Q5 容器 running 后,还要等哪几道门?
+
+- 数据库探针应完成认证查询;独立迁移作业再确认结构与 revision,成功退出后才让 API 启动并进行自己的就绪检查,running 不能替代这些条件。
+- /live 说明应用进程可响应,/ready 验证自己的数据库访问;故障时前者正常而后者失败并不矛盾。
+- 条件依赖只控制启动前置,不能保证数据库此后持续可用;运行时仍需处理失效连接与有预算的恢复,恢复后核对既有商品及新操作。
+- 迁移由一个独立作业执行,失败保留日志并阻止发布;每个 worker 各自迁移会竞争锁与版本状态,不能忽略错误后继续接流量。
+
+容易误判:“depends_on 成功一次就永远健康”误把启动顺序当持续保证;“每个副本自己改表最方便”又引入并发迁移冲突。
+
+#### Q6 换成非 root,就保护好秘密了吗?
+
+- 非 root 降低运行权限,不会擦除构建时带入的秘密;后层删除不能抹掉旧层,需按课程思路轮换已泄露凭据并重建无该层的镜像。
+- 运行时秘密文件提供受控位置,应用还需明确支持读取;环境变量名称带 FILE 不代表所有程序自动加载对应文件。
+- 本地 Compose 秘密来自宿主文件,宿主权限、备份和 Docker 管理权限仍是边界;不向应用挂 Docker socket,也不通过打印连接串证明秘密可读。
+- 用实际运行用户核对秘密可读、/app 写入被拒绝、受限 /tmp 可写,再验证 /ready 和商品查询;扫描用于定位并更新输入,不能等同安全已经证明。
+
+容易误判:“非 root 是完整隔离”和“文件挂上应用自然会读取”省略不同层面的配置与验证;只看到写入报错也不能说明业务仍正常。
+
+#### C1 把完整导出链路放进 Compose
+
+- 采用最终只接收 job_id 的任务与持久状态表,核对注册名称、参数、目录和依赖;API 与同步任务的 URL 驱动入口不同,但都指向约定数据库。
+- fast/bulk 真正各有对应消费者;publisher/recovery 的单步函数要按本课包装成有间隔、错误退避并响应停止信号的循环,不能把执行一次当成持续维护。
+- 缓存与认证 broker 分实例;输出放在相关 worker 和验证端可访问的明确共享位置。逐角色设置秘密、并发与资源,数据库连接按进程总数预算。
+- 暂停时确认新任务 queued,恢复后按同一 job_id 核对终态、有效 result_ref 与真实快照文件;同时保留实际资源上限和正常退出日志,不只截图容器 running。
+
+容易误判:“所有后台角色塞进 API 容器最省事”混淆生命周期与资源;“worker SUCCESS”也不能替代持久记录和真实文件的联合验收。
+
+#### C2 新镜像有问题,怎样证明能退回?
+
+- 记录真实旧新镜像标识、源码与基础输入、当前/目标 revision、备份恢复证据及回退窗口;标签不能替代内容标识,未推送不能编造仓库 digest。
+- 唯一迁移作业先成功,再更新应用角色;失败就停止后续切换,查实际结构与日志,不能用重启掩盖迁移失败。
+- 先验证旧镜像可在当前数据库结构和语义下读写,才把回退列为选项;删除、重命名或语义变化可能关闭回退窗口,应考虑课程讨论的前向修复。
+- 回退后检查 /ready、既有商品与一次新导出,保留原卷完整的证据及实际中断时间;不能清空数据库让旧版启动,也不由单机单副本更新承诺零停机。
+
+容易误判:“标签换回就一定恢复”和“删卷或机械 downgrade 就能撤销发布”忽略数据库与已提交数据的独立演进,可能把应用问题扩大为数据丢失。
+
+---
+
+## 07 Linux 部署:我能独立发布和恢复商品服务吗
+
+从登录目标、运行身份到入口、发布和备份,用能够复核的操作结果检查部署是否真正掌握。
+
+### 如何自测
+
+陶瓷杯和帆布袋已经能在开发电脑中查询、导出。现在由你把同一服务交给练习服务器,并为下一次更新或故障留下可用的恢复办法。
+
+- 已完成 Linux 部署模块,保留独立练习 VM、可用控制台、shop-service 发布目录,以及一次正常商品读写和导出的记录。没有该环境时先完成书面作答,将实操项保留为未验证。
+- 先独立写出每题判断、检查次序和需要的证据,再展开参考要点;有遗漏时回看指定课时,之后合上答案重新作答。
+- 实操仅限已核对的隔离 VM 和新建测试数据;沿用课程的管理入口、回退和秘密保护边界。未配置真实域名或通知渠道的能力如实注明,不能凭自评视为已验收。
+
+本轮目标:完成一份能区分已解释、已实操和未验证内容的部署自查记录,指出还需重做的具体环节。
+
+原模块讲义:[linux-lessons.md](./linux-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 旧 SSH 会话还连着,能收紧登录了吗
+
+你准备禁用密码登录。旧管理会话仍能执行命令,新建的 deploy 账号也已经放入公钥。列出现在还必须完成的验证,并解释每一项防止什么问题。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · SSH、账户与服务目录](../?module=linux&lesson=01);[02 · 监听地址、DNS 与防火墙](../?module=linux&lesson=02)
+
+#### Q2 本机能查商品,浏览器却打不开
+
+VM 内直连 API 的商品查询成功,浏览器访问站点失败。应用只监听回环地址。请画出浏览器到数据库的路径,并按顺序给出需要核对的地址、端口和观察结果。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[02 · 监听地址、DNS 与防火墙](../?module=linux&lesson=02);[05 · Nginx HTTP 入口与 TLS](../?module=linux&lesson=05)
+
+#### Q3 管理员能运行,为什么服务账号失败
+
+管理员手动导入商品代码成功,shop-api 却无法启动;另一台机器复制来的虚拟环境也在发布目录中。说明应从身份、路径、解释器和配置四方面留下什么检查证据。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · Python 环境、配置与最小权限](../?module=linux&lesson=03);[04 · systemd 管理 API、Worker 与维护进程](../?module=linux&lesson=04)
+
+#### Q4 所有单元 active,就能通知用户已恢复吗
+
+API、fast/bulk Worker 和维护进程都显示 active,但新提交的商品清单还没有文件。请列出从“进程在运行”到“业务恢复”的验证链,并指出迁移和定时器应放在哪里。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[04 · systemd 管理 API、Worker 与维护进程](../?module=linux&lesson=04);[10 · 完整发布、回退与故障 Runbook](../?module=linux&lesson=10)
+
+#### Q5 直连逐条出现,代理后为什么挤成一批
+
+商品导出进度直连 API 时逐条出现,经过 Nginx 后攒成一批;WebSocket 客户端又连不上。你如何分别验证这两种问题,什么结果才说明修复完成?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · SSE、WebSocket 与代理超时](../?module=linux&lesson=06)
+
+#### Q6 备份文件很大,就代表能恢复吗
+
+备份目录里有一份加密 SQL 文件,商品任务表也曾导出成功。如果原 VM 丢失,你还需要哪些内容和验证,才能说明商品查询与清单下载可以恢复?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[09 · 数据库备份、异机加密与恢复目标](../?module=linux&lesson=09)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 独立完成 r002 发布并证明能回退
+
+在隔离 VM 为商品服务准备一个可区分的 r002 小修改。先独立写出发布、停止条件和回退次序,再利用已学工具执行。保留 r001;不引入新业务,不删除数据库数据。需要交出哪些结果,才能证明切换与回退都真实有效?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[07 · 发布目录、原子切换与兼容回退](../?module=linux&lesson=07);[10 · 完整发布、回退与故障 Runbook](../?module=linux&lesson=10)
+
+#### C2 让一次派发停顿留下完整恢复证据
+
+先为隔离环境写一个你能解释的“最老待发事件等待过久”阈值,再暂停本次 publisher 并提交一条新商品导出,随后恢复。先独立列出要收集的时间点与记录,完成后据实填写;本题不要求接通真实通知渠道。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[08 · journald、日志轮转与可执行告警](../?module=linux&lesson=08);[10 · 完整发布、回退与故障 Runbook](../?module=linux&lesson=10)
+
+### 掌握标准与证据
+
+- [ ] 我能独立解释一条商品请求经过的身份、网络和数据边界。
+
+  证据:不看参考要点画出客户端、Nginx、API、数据库和任务角色,标出实际监听地址、运行身份、配置来源与可写目录,并解释一处连接失败应先查哪层。
+
+- [ ] 我能独立准备并启动一个可验证的新发布版本。
+
+  证据:交出一次隔离发布记录:目标路径和解释器、配置权限、迁移结果、运行角色及商品与导出探针;没有照抄他人的成功输出,未执行项单独标明。
+
+- [ ] 我能独立证明业务切换或回退后的结果符合约定。
+
+  证据:提供同一测试商品的读写记录、新任务编号、结果文件核对和版本切换前后时间;说明旧代码能否在当前表结构上运行,不以 active 代替业务验收。
+
+- [ ] 我能独立限定故障影响并验证恢复范围。
+
+  证据:交出一项受控故障的实际时间线及恢复结果,并提供一份独立恢复库的数据和文件核对记录;能够区分已测恢复时间、可能损失窗口与尚未验证能力。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 旧 SSH 会话还连着,能收紧登录了吗
+
+- 先核对主机名与主机公钥指纹,确认修改的是目标练习 VM;客户端用于登录的私钥与服务器用于证明身份的主机密钥不是同一把钥匙。
+- 保留原会话和控制台,在另一终端真正建立 deploy 密钥登录并验证管理权限;已建立的旧连接不能证明新的认证配置可用。
+- 核对 authorized_keys 所有者与权限,检查 SSH 配置语法和实际生效值;新路径通过后才收紧并重载,不能用关闭主机指纹校验掩盖身份变化。
+
+容易误判:误以为“旧会话可用,所以新登录一定没问题”。旧连接可能在认证规则改变前就已建立,退出后才会暴露无法重新登录。
+
+#### Q2 本机能查商品,浏览器却打不开
+
+- 先区分域名解析、到 VM 的网络路径、Nginx 入口与 API 上游;核对实际 A/AAAA 地址、站点选择及监听端口,不能把所有失败都归到应用代码。
+- 保留 API 回环监听,通过本机带正确 Host 的站点请求验证 Nginx 到 API;若此处成功再检查主机防火墙和云侧入口,若返回网关错误则查看该站点上游日志。
+- 宿主进程沿练习配置访问 127.0.0.1:3307,Compose 网络内才使用 mysql:3306;数据库和任务 broker 不因需要网页入口就对公网开放。
+- 先核对实际 SSH 端口,再调整限定规则并保留回退。HTTPS 还需验证真实域名证书,忽略证书校验的请求不能证明 TLS 正常。
+
+容易误判:误以为把 API、MySQL 和 Redis 全部绑定公网即可解决访问。这样混淆了各层职责,也没有定位 DNS、代理或证书的实际故障。
+
+#### Q3 管理员能运行,为什么服务账号失败
+
+- 使用真实 shop 运行身份核对解释器、依赖导入及目录每一级的进入权限;管理员的成功结果不能替代低权限进程的结果。
+- 使用目标 Linux/Python 对应的环境和已验证依赖锁,不把开发电脑的虚拟环境直接当作可迁移发布物;检查 current 实际指向的版本。
+- 发布源码由管理员维护、运行用户只读,导出结果通过共享 artifacts 目录写入;不要为解决一次权限错误把整个项目开放写权限。
+- 核对 API 与任务各自的环境文件、读取权限和驱动 URL;记录配置来源而不打印密码。主机进程使用实际主机/端口,不沿用仅容器网络能解析的服务名。
+
+容易误判:误以为管理员运行成功已证明部署正确。systemd 使用的身份、工作目录、解释器和配置可能都与手动终端不同。
+
+#### Q4 所有单元 active,就能通知用户已恢复吗
+
+- 先检查 API 真实就绪与商品读写,再按 job_id 核对任务、待发事件、执行状态和结果文件;active 仅描述进程状态,不能证明依赖可达或消息有人处理。
+- 派发器负责把已保存的意图交给队列,Worker 负责执行,恢复器处理符合条件的遗漏;它们是不同入口,应能分别定位配置和日志。
+- 迁移使用独立一次性作业,成功并核对实际结构后才推进发布,不能由每个 API 启动时抢着执行。只在已完成周期任务时单独启用一份 beat。
+- 验收应包括一条新任务完成及原未完成任务的去向;读到 result_ref 后还要确认文件存在且对应固定商品快照。
+
+容易误判:误以为 active 或一条受理响应代表导出成功。受理、派发、执行、发布文件是不同阶段,前一阶段的成功不能代替后一阶段。
+
+#### Q5 直连逐条出现,代理后为什么挤成一批
+
+- 先确认实际 /stream 和 /ws 路由已整合,分别建立直连基线;保持请求和客户端读取方式相同,再比较代理后的事件到达情况。
+- SSE 检查对应 location 的缓冲设置、空闲读取预算与实际心跳;WebSocket 检查 HTTP/1.1 及 Upgrade、Connection 转发,HTTPS 页面使用 wss。
+- Nginx 配置检查通过后再加载;分别保留逐条到达记录、101 握手和双向消息结果,不能只凭页面能打开判断连接正常。
+- 再验证断线与主动停止。未实现事件编号和续传时,应承认重新连上不能补齐中断期间漏掉的事件。
+
+容易误判:误以为统一加大超时就能修复所有长连接。缓冲、协议升级、心跳与客户端取消是不同环节,需要各自的证据。
+
+#### Q6 备份文件很大,就代表能恢复吗
+
+- 确认转储成功退出、使用兼容工具且快照条件满足;校验密文与解密后文件完整性,在独立空恢复库实际导入,不能只看文件大小。
+- 恢复端必须能取得独立保存的解密私钥和受控数据库凭据;备份 VM 仅持有加密公钥的设计不等于丢失私钥后仍可恢复。
+- 核对商品数据、迁移版本、任务状态,以及 result_ref 指向的清单文件;恢复应用使用隔离队列或禁用真实外发,避免旧任务盲目重放。
+- 记录备份恢复点、可能缺失的数据窗口与实际恢复耗时,再判断是否满足约定目标;没有 binlog 等额外恢复证据时,不承诺任意时刻恢复。
+
+容易误判:误以为数据库备份成功就能还原整项服务。文件、密钥、配置和任务恢复语义缺一项,都可能出现记录还在但无法下载或重复执行。
+
+#### C1 独立完成 r002 发布并证明能回退
+
+- 准备完整 r002、目标平台环境和共享结果路径,核对 shop 身份可读可执行;记录当前版本、revision、备份证据及新旧代码兼容范围。
+- 迁移作业先成功,核对实际结构后才切换;同文件系统原子替换 current 后仍需重启各应用角色,因为软链变化不会自动替换运行中的代码。
+- 用同一件新建测试商品和一条真实导出验收新版本,记录状态码、版本条件、job_id 与可读文件;单副本重启的中断应如实记录。
+- 仅在兼容范围内切回 r001 并重启相关角色,重复业务验收并确认已提交数据保留;若迁移破坏兼容则停止机械回退,说明为何需要其他修复路径。
+
+容易误判:误以为 current 已指回旧目录就证明回滚完成。旧进程可能尚未替换,而当前数据库结构也可能不再支持旧代码。
+
+#### C2 让一次派发停顿留下完整恢复证据
+
+- 注入前证明基线任务可完成,记录具体实例、新 job_id、预期影响及恢复动作;只暂停已核对的 publisher,保持数据库和 Worker 的原状态。
+- 把事件创建时间、最老待发年龄、publisher 单元状态和相关时间段日志对应起来,区分“已保存任务”与“已送入队列”。
+- 恢复 publisher 后记录派发、任务终态与文件验收时间,确认不是仅把积压转移到另一个阶段;日志导出不包含环境文件内容或秘密。
+- 说明告警窗口和日志保留预算,通知未接入时标为手工演练;根据实际发现与恢复用时提出一个有验收条件的改进,而不填写预计成功。
+
+容易误判:误以为写了一条告警规则就已经具备自动监控。阈值、观测来源、处理动作与真实通知渠道分别需要验证。
+
+---
+
+## 08 故障排查:我能用证据找到问题并确认恢复吗
+
+面对慢请求、缺失结果和连接异常,检查自己能否先定位阶段,再限定动作并验证真实恢复。
+
+### 如何自测
+
+商品页面有时变慢,导出记录仍显示处理中。现在没有现成的故障名称,需要你把用户现象变成可检验的判断,并把修复与恢复证据分清。
+
+- 已完成故障排查模块,准备已知正常的隔离 shop-service、副本对应的进程或容器信息,以及一组新建商品和导出测试数据。
+- 先写判断、证据和下一步,再展开参考要点。题目中的现象是待分析条件,不是你已经采集的实测结果;未执行的检查保持未验证。
+- 实操沿用课程的请求数、持续时间和资源预算,每次只注入一个故障。SSE/WebSocket 变式需先确认已整合相应路由,否则先标出缺少的前提。
+
+本轮目标:形成可反驳假设、可追踪动作和可复核恢复的自查记录,找到最需要重新练习的诊断阶段。
+
+原模块讲义:[troubleshooting-lessons.md](./troubleshooting-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 只有导出投诉时,先查什么
+
+收到“商品清单一直没有下载结果”的反馈,同时有人说商品页面还能打开。你只有进程列表和一条超时报错。请写出最先建立的影响表,以及在动服务前必须补足的证据。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · 先确认影响范围,再选择止损动作](../?module=troubleshooting&lesson=01)
+
+#### Q2 请求号、图表和追踪各能证明什么
+
+用户提供一次失败请求的编号,你又看到“五分钟错误率升高”的图表。一名同学建议给每个 job_id 建一个指标标签,并只保留 trace。请说明你怎样关联证据,以及这个建议会丢掉或增加什么。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · 定义 SLI、SLO 与低基数指标](../?module=troubleshooting&lesson=03);[04 · 把 trace 贯穿 HTTP、数据库与 Celery](../?module=troubleshooting&lesson=04)
+
+#### Q3 CPU 不高,为什么商品请求一起卡住
+
+隔离 API 的 CPU 很低,但两次慢请求会串行完成,同一时刻的健康请求也延迟。另一轮实验 CPU 明显升高。你会对比哪些数据,如何一次只改变一个因素来验证原因?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · 区分 CPU 忙、事件循环阻塞与线程池等待](../?module=troubleshooting&lesson=05)
+
+#### Q4 退出码 137 足够判定内存泄漏吗
+
+容器一次退出码为 137;另一轮里 Python 分配快照已经下降,进程 RSS 仍较高。请分别写出现在能下的结论、不能下的结论和下一项观测。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · 区分 Python 分配、RSS 与容器 OOM](../?module=troubleshooting&lesson=06)
+
+#### Q5 “查询超时”可能发生在哪个阶段
+
+三次商品相关请求分别出现:借连接等待超时、连接到未监听端口失败、数据库锁等待超时。请为每种情况选择最直接的证据与恢复检查,说明为什么不能统一扩大超时。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[07 · 分开排查 DNS、TCP、TLS 与连接池等待](../?module=troubleshooting&lesson=07);[08 · 定位慢 SQL、锁等待与连接池耗尽](../?module=troubleshooting&lesson=08)
+
+#### Q6 Worker 在线,旧任务为什么仍不完成
+
+Worker 的检查命令可以响应,但一个商品导出 job 很久仍是 queued。任务消息也出现过重复。请写出排查分支,并说明什么证据才允许你执行恢复。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 定位 Celery 积压、重试风暴与租约恢复](../?module=troubleshooting&lesson=10)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 给缓存故障做一次可复查的诊断
+
+先独立写好限定影响与恢复步骤,再在隔离环境只暂停缓存 6380,保持任务 broker 和数据库正常。用同一组小规模商品读取比较故障前后,恢复缓存后再次比较。你需要交出怎样的记录,才能支持“回源仍受控”的判断?
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[09 · 排查 Redis 热点、淘汰与缓存降级](../?module=troubleshooting&lesson=09);[12 · 完成故障演练、恢复门槛与 runbook](../?module=troubleshooting&lesson=12)
+
+#### C2 定位进度显示延后,并验证真的停止
+
+在已整合 SSE 和 WebSocket 的隔离副本中,选择一条真实代理路径。先独立设计比较方法:同一进度请求在直连、代理和浏览器中分别何时可见;用户主动停止后哪些资源应消失。使用已有探针完成核对,不新增业务。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[11 · 端到端排查 SSE 缓冲与 WebSocket 断开](../?module=troubleshooting&lesson=11);[12 · 完成故障演练、恢复门槛与 runbook](../?module=troubleshooting&lesson=12)
+
+### 掌握标准与证据
+
+- [ ] 我能独立把用户现象拆成可检验的阶段假设。
+
+  证据:不看答案,为一条商品请求或导出画出影响矩阵,分别写出事实、候选原因和能推翻原因的下一项检查,不先给出未经证实的根因。
+
+- [ ] 我能独立采集足以定位问题且不会误用的证据。
+
+  证据:提供一次限定故障中已核对的进程/容器、时间窗口、请求或任务编号以及相关指标、日志或状态表记录;标明单位、累计或增量、合成或实测和权限限制。
+
+- [ ] 我能独立用受控操作检验诊断并保留恢复路径。
+
+  证据:完成一次只改变一个因素的实验,记录原配置、请求与资源预算、停止条件及恢复动作;修复前后使用同样输入,出现意外时停止注入。
+
+- [ ] 我能独立证明恢复涉及业务结果而不只是进程状态。
+
+  证据:交出商品读写、新导出文件及旧任务去向的恢复证据,覆盖必要观察窗口,并给出一个能通过再次实验验收的改进项;未验证部分保持明确。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 只有导出投诉时,先查什么
+
+- 分别验证存活、依赖就绪、商品读取、持久任务受理和最终文件,不用一条超时推断全站不可用;没有实现的 HTTP 导出入口不能当作既有能力测试。
+- 记录实际实例、UTC 时间、最后正常时间和具体 job_id,分别标明观察事实与候选原因;进程存在不能证明数据库或文件服务可用。
+- 候选动作应有对应假设、预期改变和恢复前值,先只读检查再做限定动作;恢复判定同时追踪新导出完成及旧任务去向。
+
+容易误判:误以为先重启所有组件总能最快找原因。多个状态同时改变会破坏定位线索,进程恢复后旧任务也可能仍然遗漏。
+
+#### Q2 请求号、图表和追踪各能证明什么
+
+- 用 request_id 定位本次请求,再经受理记录关联 job_id;message_id、attempt 或租约版本帮助区分投递与执行。trace 展示阶段关系和耗时,不能替代持久业务状态。
+- 指标使用有限的路由模板、方法和状态类别;具体请求或任务编号放日志/追踪,逐编号标签会使时间序列数量随业务增长。
+- 错误比例需要明确有效样本、分母和窗口,无请求时不能报告为零错误率;耗时分位数按课程的直方图方式聚合,不平均各实例 P95。
+- 追踪可能因采样而缺失,仍应保留业务编号的日志定位链;导出或分享记录前检查敏感字段,不把查询串、完整请求或秘密直接附上。
+
+容易误判:误以为采集越多越完整,或 trace 能取代所有记录。无限编号标签会拖累监控,采样链路也不能保证每个长期任务都能找回。
+
+#### Q3 CPU 不高,为什么商品请求一起卡住
+
+- 先核对负责本次请求的真实进程,再记录同一有限负载下的 CPU、事件循环延迟、HTTP 耗时及线程等待;不能拿另一个进程的低 CPU 证明应用没有问题。
+- 异步路由内同步 sleep 可占住事件循环却不忙于计算;计算循环可能同时拉高 CPU 与调度延迟。两种现象相似,但证据组合不同。
+- 分别恢复可让出的等待,或按课程使用受限线程处理同步 I/O、受限进程处理 CPU 工作;比较相同输入,不同时扩 worker 和改代码,结束后关闭实验入口。
+
+容易误判:误以为函数写了 async 就不会阻塞,或者 CPU 低就只能是网络。异步函数里直接调用同步阻塞操作仍会让其他请求等不到执行机会。
+
+#### Q4 退出码 137 足够判定内存泄漏吗
+
+- 137 可能来自内存超限终止,也可能是其他 SIGKILL;结合 OOMKilled、cgroup 的 oom_kill 增量、限制值及退出时间判断,不凭单个退出码归因。
+- tracemalloc、RSS 和容器内存统计覆盖范围不同;释放 Python 对象后底层分配器可能暂留内存,快照下降与 RSS 未立刻下降并不矛盾。
+- 对相同有限请求批次保留多轮、同口径数据,观察同一分配位置是否持续增长;若 Python 跟踪稳定而 RSS 增长,再看原生库、缓冲或子进程,不先宣告 Python 对象泄漏。
+
+容易误判:误以为 RSS 不回落就一定泄漏,或宿主有空闲内存就不会 OOM。分配口径与容器限制分别影响这两个判断。
+
+#### Q5 “查询超时”可能发生在哪个阶段
+
+- 池等待先看连接上限、占用与获取连接的耗时;连接失败在实际调用的网络环境核对 DNS、地址和监听。一个请求可能尚未接触数据库就耗尽预算。
+- 数据库锁等待需要连接 ID、活跃事务及谁阻塞谁的关系;诊断账号看不到记录可能只是权限不足,不能把空结果当作没有阻塞。
+- MySQL 锁等待超时通常只结束失败语句,隔离两会话实验应明确回滚各自事务;再核对等待消失和商品请求恢复。Sleep 连接也可能持有长事务。
+- 增加超时可能把错误地址或锁占用转化为更久排队;先定位等待发生处,再在同等负载下检验修复。
+
+容易误判:误以为所有 timeout 都是同一种“数据库太慢”。借连接、建立连接、执行及锁等待有不同证据和处理边界。
+
+#### Q6 Worker 在线,旧任务为什么仍不完成
+
+- 先并排查持久 job、未发送 outbox、实际队列路由、Worker 注册和活动队列;queued 只能说明当前业务状态,不能证明消息已经到 broker。
+- 沿同一 job_id 区分事件 ID、消息到达、成功领取与 attempts,核对租约、next_attempt_at 和 deadline;重复消息不应自动等于又执行了一次。
+- 确认下游和容量恢复后再用有限恢复与派发流程;不能清空队列、重置 attempts 或无预算扩大并发来绕过失败保护。
+- 恢复后覆盖相关截止时间和扫描周期观察最老等待、完成速率、重试与下游错误,核对新文件和旧任务去向,而不只看队列长度短暂下降。
+
+容易误判:误以为消费者能回应就代表整条任务链正常,或清队列后“积压为零”就是恢复。待发事件可能尚未入队,清除消息还可能丢掉工作。
+
+#### C1 给缓存故障做一次可复查的诊断
+
+- 明确容器/实例、时间窗口、请求数量、恢复方式与停止条件;先采集正常基线,只暂停缓存,不能用任务 broker 的凭据或故障动作替代。
+- 比较同长度窗口的命中、未命中、淘汰与数据库请求增量,以及池等待和 HTTP 状态;累计计数应作差,不能把两次总数直接当每秒速率。
+- 确认缓存错误走有并发和时间上限的回源路径,记录真实数据库压力;超预算时明确失败,不编造商品或无限等待,也不把本进程上限当作全局上限。
+- 恢复后证明正常命中和商品读取回来,broker 未受影响;按实际证据写出哪个假设成立、哪个被排除,以及未采集项。
+
+容易误判:误以为故障期间所有请求仍返回 200 就证明降级成功。数据库可能承受了不可持续的额外压力,需要容量与等待的证据。
+
+#### C2 定位进度显示延后,并验证真的停止
+
+- 保持测试输入与读取方式一致,记录首条业务 data 的到达时间;响应头可能先到,因此不能用首字节时间替代首条业务内容时间。
+- 直连正常而代理攒批时检查对应缓冲与路径;二者正常而页面延后时检查客户端读取和渲染。只按证据修改本次隔离 location,先验证配置再加载。
+- WebSocket 单独核对 101、双向消息、关闭码与实际重连;它的升级问题不能直接用 SSE 的缓冲设置解释。
+- 主动停止后等待超过约定的最大重连间隔,确认无新连接并有服务端清理证据;恢复临时配置,最后再完成一次完整事件流与 echo。
+
+容易误判:误以为停止页面显示或断开一条连接就等于任务停止。浏览器可能自动重连,服务端也可能还在等待或继续占用资源。
+
+---
+
+## 09 分布式设计:我能解释取舍并推演失败吗
+
+用商品查询与清单导出的短场景,检验状态归属、一致性、重复、容量和恢复的因果判断;全程可以纸面完成。
+
+### 如何自测
+
+商品目录要由两个 API 副本提供,导出交给独立执行者。增加组件以后,请求会延迟、重复或遇到部分故障;需要你说明每种选择的承诺和代价。
+
+- 已完成分布式理论模块,准备纸笔或普通绘图工具;沿用商品名称、描述、整数价格、上架状态和清单导出,不增加支付、库存等业务。
+- 先独立画状态位置、时间线或列计算式,再展开参考要点。每项结论标清题设与假设,不能把纸面预计结果当作实机验证。
+- 本单元只做理论推演,不要求新代码、额外基础设施或真实故障注入;自查标准用于定位理解缺口,不是客观考试通过证明。
+
+本轮目标:给出能说明状态归属、失败窗口、资源预算和恢复边界的简洁设计,并用反例检验自己的承诺。
+
+原模块讲义:[distributed-lessons.md](./distributed-lessons.md)。离线阅读时按题目下的课号查找。
+
+### 核心理解问题
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### Q1 多开一个 API,哪些问题真的解决了
+
+API-A 在内存字典记录导出进度,下一次查询落到 API-B。两台各能稳定处理 60 次轻请求/秒,平时总流量为 90 次/秒。分别推演查询和 A 故障后的容量,再说明是否必须继续拆成很多微服务。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[01 · 从模块化单体到微服务与事件驱动](../?module=distributed&lesson=01);[02 · 高可用与负载均衡:多开一台为什么还不够](../?module=distributed&lesson=02)
+
+#### Q2 两个区域都能接请求,为什么仍不能随意确认修改
+
+两区都保存商品 42 的 version=7,区间网络中断但用户仍可分别访问。东区把名称改成“白杯”,西区改成“透明杯”。比较“只有可联系权威边界的一侧确认”与“两侧都确认”的结果,并说明列表读取可否采用不同约定。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · 复制与分片:多份副本和分成几份有什么不同](../?module=distributed&lesson=03);[04 · CAP 与一致性:断网时,谁还能回答](../?module=distributed&lesson=04)
+
+#### Q3 改价并删缓存,为何旧价仍能回来
+
+A 读到商品旧价格 1990、version=7 后暂停;B 写入 2490、version=8 并删除缓存;A 随后回填。写出最终可能看到的库与缓存,再说明延迟再删一次是否已经形成绝对保证。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[05 · 缓存的四类难题:旧值、穿透、击穿与雪崩](../?module=distributed&lesson=05)
+
+#### Q4 提交超时之后,究竟该不该再建一份任务
+
+商品导出任务和待发送记录已在数据库同次提交,但响应丢失。用户立即重试,发布器也可能重发同一事件;次日用户又主动导出同样商品。请分别判断这三种重复,并指出 outbox 没有替你保证什么。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[07 · 跨系统事务:本地事务、Outbox 与 Saga](../?module=distributed&lesson=07);[08 · 幂等与未知结果:超时以后要不要再来一次](../?module=distributed&lesson=08)
+
+#### Q5 旧执行者恢复时,新序号能挡住哪些写入
+
+A 的租约到期后 B 接管,A 随后从暂停中恢复。A 序号 41,B 序号 42。分别推演资源已接受 42 和资源尚未见过 42 时 A 的写入;再说明释放锁与发布终态各需什么保护。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[09 · 分布式锁与租约:过期后旧 Worker 为什么还在写](../?module=distributed&lesson=09)
+
+#### Q6 队列加长后,用户需要再等多久
+
+导出每秒到达 7 个、完成 5 个,持续 30 秒;高峰后仍每秒到达 3 个。先估算积压和清空时间,再比较两层调用各最多尝试 3 次时的最坏下游请求数,说明应怎样限制新增压力。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[10 · 超时、重试、熔断与隔离:阻止局部故障扩散](../?module=distributed&lesson=10);[11 · 限流、背压与容量:队列只能借时间](../?module=distributed&lesson=11)
+
+### 情境推演
+
+先写下判断、原因与验证方式,再对照本单元末尾的参考要点。
+
+#### C1 为商品清单画一条完整的失败可恢复路径
+
+只做纸面设计:用户选择少量商品提交导出,关闭页面后还能回来查询并下载,也能请求取消。先独立画最小方案,给定你自己的教学用数量上限和完成时限,再在消息确认、Worker 执行和文件返回三个位置插入失败。不要新增订单或支付系统。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[06 · 消息队列:可靠交付、重复与顺序](../?module=distributed&lesson=06);[12 · 可观测性、容灾与一道完整的系统设计题](../?module=distributed&lesson=12)
+
+#### C2 区域恢复后,哪一项能力应先开放
+
+只做纸面变式:恢复副本只包含某个时间点的任务数据,部分任务显示完成但清单文件尚未找回,另一批显示处理中;可用资源暂时有限。请排出恢复与开放服务次序,说明损失窗口、用户提示和不能立即重放的原因。
+
+我的回答:
+
+自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演
+
+需要时回学:[03 · 复制与分片:多份副本和分成几份有什么不同](../?module=distributed&lesson=03);[12 · 可观测性、容灾与一道完整的系统设计题](../?module=distributed&lesson=12)
+
+### 掌握标准与证据
+
+- [ ] 我能独立解释增加副本、拆分进程和复制数据分别改变什么。
+
+  证据:不看答案画出同一商品查询与导出的状态归属,使用一个副本故障或读到旧值的反例说明各自解决的问题、剩余限制及新增网络成本。
+
+- [ ] 我能独立用时间线检验一致性和重复处理承诺。
+
+  证据:纸面列出缓存旧值回填、响应丢失或旧 Worker 恢复中的至少两种交错,标出每次读写、证据与原子边界,并指出结论依赖的假设。
+
+- [ ] 我能独立计算容量和等待,而不把估计写成保证。
+
+  证据:用一组不同于课件的到达率、处理率和持续时间,写出净积压、净消化及重试放大计算,注明单位、稳定耗时假设和最大等待超过目标时的处理。
+
+- [ ] 我能独立给出带失败恢复和明确取舍的轻量方案。
+
+  证据:交出一页商品清单导出设计,含正常路径、两个失败分支、可查询证据、恢复先后与降级提示;能用反例说明不能保证什么,全程无需新代码或真实故障注入。
+
+### 本轮结果与回学计划
+
+八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。
+
+需要回学的题号:
+
+尚缺的证据:
+
+下次复测时准备改变的条件:
+
+### 参考要点与易错点
+
+完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。
+
+#### Q1 多开一个 API,哪些问题真的解决了
+
+- B 没有 A 的内存记录,可能错误地回答任务不存在;可跨副本查询的任务状态需要共享的可靠记录,“API 无状态”不代表整个系统没有状态。
+- 一台故障后只剩 60 次/秒能力,对 90 次/秒需求有 30 次/秒缺口;负载均衡和健康检查不会自动创造容量,切换也有发现与在途失败窗口。
+- 把耗时导出放到独立 Worker 可以单独分配资源,商品模块仍可保留在一个应用中;继续拆分应有独立发布、扩容等具体原因,并承担新网络边界成本。
+
+容易误判:误以为多开副本会自动共享内存、提供足够故障余量,或必须把每个模块做成微服务。副本、状态保存和服务边界是不同选择。
+
+#### Q2 两个区域都能接请求,为什么仍不能随意确认修改
+
+- 只允许满足协调条件的一侧确认,可以保护同一旧版本不会在两区被各自成功修改;无法满足条件的一侧需要拒绝或等待,部分请求不能正常完成。
+- 两侧都按本地 version=7 确认后会产生冲突;恢复通信仍需取舍、合并或人工确认,不能回头宣称当时有唯一的跨区域修改顺序。
+- 列表展示可以在明确时效约定下读较近副本,保存修改则使用保护版本条件的权威边界;副本落后时读到旧值本身不能证明写入已丢。
+- 网络分区不是把数据库记录分片;即使正常通信,复制仍会有延迟和成本,CAP 也不是平时随意勾选两个功能的菜单。
+
+容易误判:误以为两个区域都能被用户访问就不存在分区,或者稍后最终一致能保证此前每次修改都符合单一顺序。缺失的是区域间协调信息。
+
+#### Q3 改价并删缓存,为何旧价仍能回来
+
+- 数据库可以是 2490/v8,缓存却被 A 回填成 1990/v7;按次序更新数据库再删除缓存仍存在这个并发交错窗口。
+- 再删除一次可能缩小部分窗口,但把 A 的暂停拉长仍可能在第二次删除后回填;固定延迟不能证明所有慢请求已经结束。
+- 应说明该商品展示能容忍多旧的数据,并按课程选择版本条件、可靠失效及受控回源等组合;不能把容忍旧读与必须保护版本的修改混成同一承诺。
+- 另行区分不存在编号、单一热点过期和大批键过期的压力来源,分别选择校验或负缓存、合并回源、打散到期及容量保护,不用双删回答所有缓存问题。
+
+容易误判:误以为写后删除或延迟双删能无条件保证缓存永不旧。它们的效果受并发时序影响,必须明确可验证的保障范围。
+
+#### Q4 提交超时之后,究竟该不该再建一份任务
+
+- 第一次提交与响应丢失后的重试属于同一意图,应沿稳定意图编号找回同一任务;次日主动发起的新需求通常使用新编号,不能只按正文哈希吞掉它。
+- 任务与待发送记录同事务保存,使受理事实和派发意图一起保留;发布器发送成功但来不及确认时仍可能重发,因此接收和执行需要自己的业务保护。
+- 两个 API 同时受理同一意图时,先查不存在再写不够,应由权威存储的唯一约束与事务决定创建结果;去重记录保留时间需覆盖接口承诺。
+- outbox 已发送不等于 Worker 完成,更不保证外部文件写只发生一次;远端结果未知时优先按稳定对象标识或幂等语义核实,不能把超时一律当失败重做。
+
+容易误判:误以为 outbox 就是恰好执行一次,或内容相同都该去重。持久派发意图、重复交付与用户的新意图属于不同边界。
+
+#### Q5 旧执行者恢复时,新序号能挡住哪些写入
+
+- 租约到期不意味着 A 的进程停止,所以 A、B 可能同时运行;A 释放资格时须原子核对所有者,不能删掉 B 的锁。
+- 资源已经接受并记录 42 后,应拒绝 41;检查序号、更新最大值与写数据需在同一可靠原子边界内,避免检查后被插队。
+- 若资源只拒绝小于已见最大值而尚未见 42,仍可能接受 41;这种 fencing 不自动保证租约一过期就拒绝所有旧写,承诺更强时需额外的权威有效版本验证。
+- 终态可用状态和版本条件限制发布,计算阶段允许重复但控制成本;已发出的远端副作用仍需自己的核实或幂等保护,不能靠旧进程主动停止倒追回去。
+
+容易误判:误以为锁过期会杀掉旧进程,或者 fencing 能无条件阻止一切过期写。必须说明资源已经见过什么序号,以及真正写入在哪个边界验证。
+
+#### Q6 队列加长后,用户需要再等多久
+
+- 高峰净积压为每秒 2 个,30 秒新增约 60 个;假设耗时稳定、无其他瓶颈,高峰后净消化为每秒 2 个,约需 30 秒清空。
+- 不能用积压直接除以总处理能力,因为新任务仍在到达;还应看最老任务年龄、耗时波动和文件字节吞吐,计算值只是带假设的估计。
+- 两层各最多 3 次尝试,最坏下游可被调用 9 次;重试会消耗剩余时间与资源,应选择合适层主导,并包含连接、等待和退避的总预算。
+- 队列只吸收短时突发,超过用户可接受等待时应限制受理或准确降级;给导出独立资源预算可以保留商品查询,恢复后逐步放量。
+
+容易误判:误以为队列越长越可靠,或每一层自行重试都只增加一点压力。长期供给不足不会因排队消失,多层尝试次数还会相乘。
+
+#### C1 为商品清单画一条完整的失败可恢复路径
+
+- 图中明确商品与固定快照、任务状态、派发意图、队列、Worker 和结果文件的归属;提交时保存可继续恢复的事实,文件就绪后才发布可下载引用。
+- 分别推演早确认后未执行、完成后确认丢失、文件结果未知等窗口;每个恢复箭头都应有持久记录或可查询证据,不能只标“重试”。
+- 重复投递使用同一业务身份并保护终态,取消必须阻止不符合状态条件的成功发布;同商品逐条动作若要求顺序,应明确局部顺序范围,不以最新版本过滤代替所有动作。
+- 写出输入、并发或受理上限与完成目标,记录最老等待和完成耗时等观测;说明资源不足时用户看到什么,以及哪些承诺需实机验证才能确认。
+
+容易误判:误以为把所有组件画全就完成设计。没有状态归属、失败后的证据和恢复条件,图上的重试箭头无法说明工作会怎样继续。
+
+#### C2 区域恢复后,哪一项能力应先开放
+
+- 先确认权威数据恢复点、当前写入归属及配置密钥可用性,再恢复入口和任务协调执行;落后副本不等于历史备份,也不保证包含所有已确认变化。
+- 核对已完成记录与 result_ref 文件,处理中记录与外部可能已发生的效果;文件缺失不能显示可下载成功,未知效果不能无条件重做。
+- 资源有限可先恢复商品与任务查询,明确暂缓批量导出;异步重放仍需保留业务标识、版本与恢复规则,防止旧执行结果覆盖或产生重复副作用。
+- 从恢复点估计可能丢失的数据窗口,从恢复到可用所需步骤估计时间,并分别对照 RPO/RTO 目标;题面没有实测,不能把预算或预计时间写成已达到的保证。
+
+容易误判:误以为任务表恢复完成就可立即开放全部服务并重发所有未完成任务。文件、权威写入位置与外部效果还未核实,重复和虚假成功都可能出现。
+
+---
+

Dosya farkı çok büyük olduğundan ihmal edildi
+ 386 - 131
public/downloads/tasks-lessons.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 421 - 133
public/downloads/troubleshooting-lessons.md


+ 88 - 24
scripts/build-course-content.mjs

@@ -1,5 +1,6 @@
 import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
 import { resolve } from 'node:path';
+import { verifyReview, reviewMarkdown } from './review-content.mjs';
 const root = process.cwd();
 const catalog = [
   ['api', 'API 服务开发', 16],
@@ -10,19 +11,60 @@ const catalog = [
   ['docker', 'Docker 容器化', 10],
   ['linux', 'Linux 部署', 10],
   ['troubleshooting', '线上问题排查', 12],
+  ['distributed', '分布式系统设计', 12],
+  ['review', '复盘与自测', 9],
 ];
 const errors = [];
 let total = 0;
 const report = [];
+const validatedModules = [];
 mkdirSync(resolve(root, 'public/downloads'), { recursive: true });
 const text = (value) => typeof value === 'string' && value.trim().length > 0;
 const list = (value, min = 1) => Array.isArray(value) && value.length >= min;
 function verify(l, slug, index) {
+  if (slug === 'review') {
+    errors.push(
+      ...verifyReview(
+        l,
+        index,
+        new Map(
+          validatedModules.map((module) => [module.slug, module.lessons]),
+        ),
+      ),
+    );
+    return;
+  }
   const prefix = `${slug}/${l?.id || index}`;
+  const theory = slug === 'distributed';
+  if (
+    theory &&
+    (l.kind !== 'theory' ||
+      l.production ||
+      l.steps?.some((s) => s.code || s.lang || s.filename))
+  )
+    errors.push(
+      `${prefix}: theory lessons must not contain practical code or production drills`,
+    );
+  if (
+    theory &&
+    (!text(l.interview?.question) ||
+      !text(l.interview?.answer) ||
+      !list(l.interview?.followUps))
+  )
+    errors.push(`${prefix}: missing interview question, answer or follow-ups`);
   if (l?.id !== String(index + 1).padStart(2, '0'))
     errors.push(`${prefix}: invalid lesson ID`);
   for (const key of ['group', 'title', 'summary'])
     if (!text(l?.[key])) errors.push(`${prefix}: missing ${key}`);
+  if (
+    !text(l.intro?.scenario) ||
+    !text(l.intro?.outcome) ||
+    !list(l.intro?.startingPoint) ||
+    l.intro.startingPoint.some((item) => !text(item))
+  )
+    errors.push(
+      `${prefix}: missing self-study scenario, starting point or outcome`,
+    );
   if ('minutes' in l)
     errors.push(`${prefix}: instructor duration metadata is not allowed`);
   for (const key of [
@@ -37,9 +79,19 @@ function verify(l, slug, index) {
   for (const c of l.concepts || [])
     if (!text(c.title) || !text(c.body))
       errors.push(`${prefix}: incomplete concept`);
-  for (const s of l.steps || [])
-    if (!text(s.title) || !text(s.body))
-      errors.push(`${prefix}: incomplete step`);
+  for (const [stepIndex, s] of (l.steps || []).entries()) {
+    const stepPrefix = `${prefix}/step-${stepIndex + 1}`;
+    for (const key of ['title', 'body', 'expected', 'explanation'])
+      if (!text(s[key])) errors.push(`${stepPrefix}: missing ${key}`);
+    if (
+      s.expected === s.body ||
+      s.explanation === s.body ||
+      s.expected === s.explanation
+    )
+      errors.push(
+        `${stepPrefix}: action, observation and explanation must be distinct`,
+      );
+  }
   for (const p of l.pitfalls || [])
     if (!text(p.symptom) || !text(p.cause) || !text(p.fix))
       errors.push(`${prefix}: incomplete troubleshooting`);
@@ -63,12 +115,13 @@ function verify(l, slug, index) {
       !list(l.pitfalls, 2) ||
       !list(l.refs, 2)
     )
-      errors.push(`${prefix}: insufficient practical lesson detail`);
+      errors.push(`${prefix}: insufficient lesson detail`);
     if (
-      !text(l.production?.scenario) ||
-      !list(l.production?.decisions) ||
-      !text(l.production?.failureDrill) ||
-      !list(l.production?.acceptance)
+      !theory &&
+      (!text(l.production?.scenario) ||
+        !list(l.production?.decisions) ||
+        !text(l.production?.failureDrill) ||
+        !list(l.production?.acceptance))
     )
       errors.push(`${prefix}: incomplete production exercise`);
   }
@@ -79,32 +132,40 @@ function markdown(title, lessons) {
     `# ${title}\n\n术核 AI · 后端工程课件\n\n` +
     lessons
       .map((l) => {
-        let out = `## ${l.id} ${l.title}\n\n${l.summary}\n\n### 本节目标\n\n${bullets(l.objectives)}\n\n### 必要概念\n\n`;
-        out += l.concepts
-          .map((c) => `#### ${c.title}\n\n${c.body}\n`)
-          .join('\n');
+        const theory = l.kind === 'theory';
+        let out = `## ${l.id} ${l.title}\n\n${l.summary}\n\n### 从一个问题开始\n\n${l.intro.scenario}\n\n**这次的终点**\n\n${l.intro.outcome}\n\n#### 开始前,确认你在这里\n\n${bullets(l.intro.startingPoint)}\n\n`;
+        out += theory
+          ? '先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。\n'
+          : '每次只做一个步骤,先核对结果,再读原因。结果不一致时,先排查当前步骤,再继续往下。\n';
+        out += `\n### 这节课要完成什么\n\n${bullets(l.objectives)}\n`;
         out +=
-          '\n### 动手实现\n\n' +
+          `\n### ${theory ? '一步步推演场景' : '一步步完成案例'}\n\n` +
           l.steps
             .map(
               (s, i) =>
-                `#### ${i + 1}. ${s.title}\n\n${s.body}\n${s.code ? `\n文件:${s.filename || '终端 / 当前课时'}\n\n\`\`\`\`${s.lang || 'text'}\n${s.code}\n\`\`\`\`\n` : ''}`,
+                `#### ${i + 1}. ${s.title}\n\n${s.body}\n${s.code ? `\n文件:${s.filename || '终端 / 当前课时'}\n\n\`\`\`\`${s.lang || 'text'}\n${s.code}\n\`\`\`\`\n` : ''}\n**${theory ? '对照你的推演' : '你应该看到什么'}**\n\n${s.expected}\n\n**${theory ? '从这里理解原因' : '刚才发生了什么'}**\n\n${s.explanation}\n`,
             )
             .join('\n');
-        out += `\n### 验收结果\n\n${bullets(l.checks)}\n`;
+        out += `\n### ${theory ? '检查推演结果' : '检查学习结果'}\n\n${bullets(l.checks)}\n`;
         if (l.production) {
           const p = l.production;
-          out += `\n### 生产约束与故障演练\n\n${p.scenario}\n\n#### 实现决策\n\n${bullets(p.decisions)}\n\n#### 隔离环境演练\n\n${p.failureDrill}\n\n#### 恢复验收\n\n${bullets(p.acceptance)}\n`;
+          out += `\n### 进一步验证:约束与故障\n\n先通过上面的学习结果检查,再做这一组实验。\n\n${p.scenario}\n\n#### 实现决策\n\n${bullets(p.decisions)}\n\n#### 隔离环境演练\n\n${p.failureDrill}\n\n#### 恢复验收\n\n${bullets(p.acceptance)}\n`;
         }
         out +=
-          '\n### 常见错误\n\n' +
+          `\n### ${theory ? '常见误区' : '遇到问题时查这里'}\n\n` +
           l.pitfalls
             .map(
               (p) =>
                 `#### ${p.symptom}\n\n原因:${p.cause}\n\n处理:${p.fix}\n`,
             )
             .join('\n');
-        out += `\n### 扩展练习\n\n${l.exercise.task}\n\n提示:${l.exercise.hint}\n\n参考答案:\n\n${l.exercise.answer}\n\n### 技术文档\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`;
+        if (l.interview)
+          out += `\n### 面试问答\n\n${l.interview.question}\n\n${l.interview.answer}\n\n追问:\n\n${bullets(l.interview.followUps)}\n`;
+        out += `\n### ${theory ? '自己设计一次' : '自己试一次'}\n\n${l.exercise.task}\n\n提示:${l.exercise.hint}\n\n参考答案:\n\n${l.exercise.answer}\n\n### 核心概念回顾\n\n${theory ? '回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。' : '回想刚才的操作、现象与选择,用下面的概念解释你已经完成的案例。'}\n\n`;
+        out += l.concepts
+          .map((c) => `#### ${c.title}\n\n${c.body}\n`)
+          .join('\n');
+        out += `\n### ${theory ? '延伸阅读' : '技术文档'}\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`;
         return out;
       })
       .join('\n---\n\n')
@@ -128,16 +189,13 @@ for (const [slug, title, count] of catalog) {
   }
   lessons.forEach((l, i) => verify(l, slug, i));
   total += lessons.length;
-  writeFileSync(
-    resolve(root, `public/downloads/${slug}-lessons.md`),
-    markdown(title, lessons),
-  );
+  validatedModules.push({ slug, title, lessons });
   report.push({
     slug,
     lessons: lessons.length,
-    steps: lessons.reduce((n, l) => n + l.steps.length, 0),
+    steps: lessons.reduce((n, l) => n + (l.steps?.length || 0), 0),
     codeBlocks: lessons.reduce(
-      (n, l) => n + l.steps.filter((s) => s.code).length,
+      (n, l) => n + (l.steps?.filter((s) => s.code).length || 0),
       0,
     ),
   });
@@ -146,6 +204,12 @@ if (errors.length) {
   console.error(errors.join('\n'));
   process.exit(1);
 }
+for (const { slug, title, lessons } of validatedModules) {
+  writeFileSync(
+    resolve(root, `public/downloads/${slug}-lessons.md`),
+    slug === 'review' ? reviewMarkdown(lessons) : markdown(title, lessons),
+  );
+}
 console.log(
   JSON.stringify(
     { modules: report.length, lessons: total, details: report },

+ 134 - 0
scripts/check-course-diagrams.mjs

@@ -0,0 +1,134 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+import {
+  teachingDiagrams,
+  getModuleDiagrams,
+  hasLessonDiagrams,
+} from '../app/diagram-catalog.ts';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const modules = [
+  'api',
+  'database',
+  'api-database',
+  'tasks',
+  'redis',
+  'docker',
+  'linux',
+  'troubleshooting',
+  'distributed',
+];
+const lessons = new Map();
+for (const moduleSlug of modules) {
+  const file =
+    moduleSlug === 'api'
+      ? 'app/lessons.json'
+      : `public/content/${moduleSlug}.json`;
+  const content = JSON.parse(await readFile(path.join(root, file), 'utf8'));
+  lessons.set(moduleSlug, new Set(content.map((lesson) => lesson.id)));
+}
+
+const diagramIds = new Set();
+for (const diagram of teachingDiagrams) {
+  assert(!diagramIds.has(diagram.id), `Duplicate diagram: ${diagram.id}`);
+  diagramIds.add(diagram.id);
+  assert(
+    lessons.has(diagram.module),
+    `${diagram.id}: unknown module ${diagram.module}`,
+  );
+  assert(diagram.lessons.length > 0, `${diagram.id}: missing lesson mapping`);
+  assert.equal(
+    new Set(diagram.lessons).size,
+    diagram.lessons.length,
+    `${diagram.id}: duplicate lesson mapping`,
+  );
+  for (const lesson of diagram.lessons) {
+    assert(
+      lessons.get(diagram.module).has(lesson),
+      `${diagram.id}: unknown lesson ${diagram.module}/${lesson}`,
+    );
+  }
+  const nodeIds = new Set(diagram.nodes.map((node) => node.id));
+  assert.equal(
+    nodeIds.size,
+    diagram.nodes.length,
+    `${diagram.id}: duplicate node ID`,
+  );
+  assert(
+    diagram.steps.length >= 3 && diagram.misconception.length > 0,
+    `${diagram.id}: missing text explanation`,
+  );
+  for (const node of diagram.nodes) {
+    assert(
+      node.x >= 0 && node.x + 210 <= 900,
+      `${diagram.id}/${node.id}: node outside SVG width`,
+    );
+    assert(
+      node.y >= 0 && node.y + 104 <= diagram.rows.length * 185 + 60,
+      `${diagram.id}/${node.id}: node outside SVG height`,
+    );
+    const lineCount = (text, maxUnits) => {
+      let lines = 1;
+      let units = 0;
+      for (const character of text) {
+        const width = /[\u0020-\u007e]/.test(character) ? 1 : 2;
+        if (units + width > maxUnits) {
+          lines++;
+          units = 0;
+        }
+        units += width;
+      }
+      return lines;
+    };
+    const bottomBaseline =
+      26 +
+      lineCount(node.title, 24) * 19 +
+      5 +
+      (lineCount(node.detail, 29) - 1) * 16;
+    assert(
+      bottomBaseline <= 98,
+      `${diagram.id}/${node.id}: text exceeds node height`,
+    );
+  }
+  for (const edge of diagram.edges) {
+    assert(
+      nodeIds.has(edge.from) && nodeIds.has(edge.to),
+      `${diagram.id}: dangling edge ${edge.from} -> ${edge.to}`,
+    );
+    for (const [x, y] of [
+      ...(edge.via ?? []),
+      ...(edge.labelAt ? [edge.labelAt] : []),
+    ]) {
+      assert(
+        x >= 0 && x <= 900 && y >= 0 && y <= diagram.rows.length * 185 + 60,
+        `${diagram.id}: edge outside viewBox`,
+      );
+    }
+  }
+}
+
+for (const moduleSlug of modules) {
+  assert(
+    getModuleDiagrams(moduleSlug).length >= 1,
+    `${moduleSlug}: missing overview diagram`,
+  );
+}
+for (const lesson of lessons.get('distributed')) {
+  assert(
+    hasLessonDiagrams('distributed', lesson),
+    `distributed/${lesson}: missing diagram`,
+  );
+}
+console.log(
+  `Validated ${teachingDiagrams.length} diagrams: unique IDs, valid edges, text bounds, lesson mappings, 9 module overviews, and all distributed lessons.`,
+);
+for (const moduleSlug of modules) {
+  const covered = [...lessons.get(moduleSlug)].filter((lesson) =>
+    hasLessonDiagrams(moduleSlug, lesson),
+  ).length;
+  console.log(
+    `${moduleSlug}: ${teachingDiagrams.filter((diagram) => diagram.module === moduleSlug).length} diagrams, ${covered}/${lessons.get(moduleSlug).size} lessons illustrated`,
+  );
+}

+ 3 - 0
scripts/check-python-snippets.py

@@ -12,6 +12,9 @@ errors = []
 for path in files:
     lessons = json.loads(path.read_text(encoding="utf-8-sig"))
     for lesson in lessons:
+        if lesson.get("kind") == "review":
+            assert "steps" not in lesson, "Review units must not contain executable steps"
+            continue
         for index, step in enumerate(lesson["steps"], 1):
             if step.get("lang") not in {"python", "py"} or not step.get("code"):
                 continue

+ 166 - 0
scripts/check-review.mjs

@@ -0,0 +1,166 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { reviewModules, verifyReview } from './review-content.mjs';
+import {
+  emptyReviewState,
+  restoreReviewState,
+  reviewProgress,
+  reviewSignature,
+} from '../app/review-state.ts';
+
+const read = (path) => JSON.parse(readFileSync(path, 'utf8'));
+const lessons = read('public/content/review.json');
+const sources = new Map(
+  reviewModules.map((slug) => [
+    slug,
+    read(slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`),
+  ]),
+);
+assert.equal(lessons.length, 9);
+const markdown = readFileSync('public/downloads/review-lessons.md', 'utf8');
+let items = 0;
+let criteria = 0;
+for (const [index, lesson] of lessons.entries()) {
+  assert.deepEqual(verifyReview(lesson, index, sources), []);
+  const start = markdown.indexOf(`## ${lesson.id} ${lesson.title}\n`);
+  const end =
+    index < lessons.length - 1
+      ? markdown.indexOf(
+          `## ${lessons[index + 1].id} ${lessons[index + 1].title}\n`,
+          start + 1,
+        )
+      : markdown.length;
+  assert(start >= 0 && end > start, `missing review/${lesson.id} download`);
+  const text = markdown.slice(start, end);
+  const headings = [
+    '### 如何自测',
+    '### 核心理解问题',
+    '### 情境推演',
+    '### 掌握标准与证据',
+    '### 本轮结果与回学计划',
+    '### 参考要点与易错点',
+  ];
+  const positions = headings.map((heading) => text.indexOf(heading));
+  assert(
+    positions.every(
+      (position, i) => position >= 0 && (!i || position > positions[i - 1]),
+    ),
+    'questions must precede reference answers',
+  );
+  for (const item of [...lesson.questions, ...lesson.challenges]) {
+    assert(
+      text.slice(0, positions[5]).includes(item.prompt),
+      `${item.id}: missing question`,
+    );
+    for (const point of item.checkpoints)
+      assert(
+        text.slice(positions[5]).includes(point),
+        `${item.id}: missing answer point`,
+      );
+    assert(text.slice(positions[5]).includes(item.misconception));
+    for (const link of item.revisit)
+      assert(text.includes(`${link.lessonId} · ${link.label}`));
+    items++;
+  }
+  for (const item of lesson.mastery) {
+    assert(text.includes(item.text) && text.includes(item.evidence));
+    criteria++;
+  }
+
+  const empty = emptyReviewState();
+  assert.equal(
+    reviewProgress(lesson, empty).mastered,
+    false,
+    'opening a review does not mean mastery',
+  );
+  const answered = {
+    ...empty,
+    answers: Object.fromEntries(
+      [...lesson.questions, ...lesson.challenges].map((item) => [
+        item.id,
+        'ready',
+      ]),
+    ),
+  };
+  assert.equal(
+    reviewProgress(lesson, answered).mastered,
+    false,
+    'self-rated answers alone cannot pass the evidence gate',
+  );
+  const complete = {
+    ...answered,
+    evidence: Object.fromEntries(lesson.mastery.map((item) => [item.id, true])),
+    notes: { q1: '我的判断与证据' },
+  };
+  assert.equal(reviewProgress(lesson, complete).mastered, true);
+  for (const item of [...lesson.questions, ...lesson.challenges]) {
+    assert.equal(
+      reviewProgress(lesson, {
+        ...complete,
+        answers: { ...complete.answers, [item.id]: 'revisit' },
+      }).mastered,
+      false,
+      'one unresolved item must keep the module incomplete',
+    );
+  }
+  assert.equal(
+    reviewProgress(lesson, {
+      ...complete,
+      evidence: { ...complete.evidence, m1: false },
+    }).mastered,
+    false,
+  );
+  const raw = JSON.stringify({
+    sourceModule: lesson.sourceModule,
+    signature: reviewSignature(lesson),
+    state: complete,
+  });
+  assert.deepEqual(
+    restoreReviewState(raw, lesson),
+    complete,
+    'answers and notes must survive reload',
+  );
+  const other = lessons[(index + 1) % lessons.length];
+  assert.deepEqual(
+    restoreReviewState(raw, other),
+    emptyReviewState(),
+    'ratings must not leak between modules',
+  );
+  assert.deepEqual(
+    restoreReviewState(raw, {
+      ...lesson,
+      summary: lesson.summary + ' changed',
+    }),
+    complete,
+    'editorial summaries must not invalidate self-ratings',
+  );
+  const changed = restoreReviewState(raw, {
+    ...lesson,
+    questions: lesson.questions.map((item, i) =>
+      i === 0 ? { ...item, prompt: item.prompt + ' changed' } : item,
+    ),
+  });
+  assert.deepEqual(
+    changed,
+    { ...emptyReviewState(), notes: complete.notes, contentChanged: true },
+    'changed questions preserve notes but require fresh self-ratings',
+  );
+  assert.deepEqual(restoreReviewState('{broken', lesson), emptyReviewState());
+  const invalid = JSON.stringify({
+    sourceModule: lesson.sourceModule,
+    signature: reviewSignature(lesson),
+    state: {
+      answers: { q1: 'maybe' },
+      notes: { q1: 123 },
+      evidence: { m1: 'true' },
+    },
+  });
+  assert.deepEqual(
+    restoreReviewState(invalid, lesson),
+    emptyReviewState(),
+    'invalid saved fields cannot count as evidence',
+  );
+}
+console.log(
+  `Review: ${lessons.length} units, ${items} questions/challenges, ${criteria} mastery criteria; return links, download order, evidence gate, persistence, isolation and corrupt storage passed.`,
+);

+ 131 - 0
scripts/check-self-study.mjs

@@ -0,0 +1,131 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const modules = [
+  'api',
+  'database',
+  'api-database',
+  'tasks',
+  'redis',
+  'docker',
+  'linux',
+  'troubleshooting',
+  'distributed',
+];
+let lessonsChecked = 0;
+let stepsChecked = 0;
+for (const slug of modules) {
+  const lessons = JSON.parse(
+    readFileSync(
+      resolve(
+        slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`,
+      ),
+      'utf8',
+    ).replace(/^\uFEFF/, ''),
+  );
+  const markdown = readFileSync(
+    resolve(`public/downloads/${slug}-lessons.md`),
+    'utf8',
+  );
+  for (const [index, lesson] of lessons.entries()) {
+    const label = `${slug}/${lesson.id}`;
+    const marker = `## ${lesson.id} ${lesson.title}\n`;
+    const start = markdown.indexOf(marker);
+    assert(start >= 0, `${label}: missing downloaded lesson`);
+    const next = lessons[index + 1];
+    const end = next
+      ? markdown.indexOf(`## ${next.id} ${next.title}\n`, start + marker.length)
+      : markdown.length;
+    assert(end > start, `${label}: lesson order changed`);
+    const lessonText = markdown.slice(start, end);
+    assert(
+      !lessonText.includes('### 必要概念'),
+      `${label}: concepts still front-loaded`,
+    );
+    const theory = lesson.kind === 'theory';
+    const positions = [
+      '### 从一个问题开始',
+      '### 这节课要完成什么',
+      `### ${theory ? '一步步推演场景' : '一步步完成案例'}`,
+      `### ${theory ? '检查推演结果' : '检查学习结果'}`,
+      `### ${theory ? '自己设计一次' : '自己试一次'}`,
+      '### 核心概念回顾',
+      `### ${theory ? '延伸阅读' : '技术文档'}`,
+    ].map((heading) => {
+      const position = lessonText.indexOf(heading);
+      assert(position >= 0, `${label}: missing section ${heading}`);
+      return position;
+    });
+    assert(
+      positions.every((position, i) => !i || position > positions[i - 1]),
+      `${label}: self-study reading order is incorrect`,
+    );
+    for (const content of [
+      lesson.intro.scenario,
+      lesson.intro.outcome,
+      ...lesson.intro.startingPoint,
+    ]) {
+      assert(
+        lessonText.slice(0, positions[2]).includes(content),
+        `${label}: preparation missing from download`,
+      );
+    }
+    let cursor = positions[2];
+    for (const [stepIndex, step] of lesson.steps.entries()) {
+      const heading = `#### ${stepIndex + 1}. ${step.title}\n`;
+      const at = lessonText.indexOf(heading, cursor);
+      assert(
+        at >= cursor && at < positions[3],
+        `${label}: step ${stepIndex + 1} missing or misplaced`,
+      );
+      const nextStep = lesson.steps[stepIndex + 1];
+      const until = nextStep
+        ? lessonText.indexOf(
+            `#### ${stepIndex + 2}. ${nextStep.title}\n`,
+            at + heading.length,
+          )
+        : positions[3];
+      assert(until > at, `${label}: step boundaries are incorrect`);
+      const stepText = lessonText.slice(at, until);
+      const actionAt = stepText.indexOf(step.body);
+      const expectedAt = stepText.indexOf(
+        `**${theory ? '对照你的推演' : '你应该看到什么'}**`,
+      );
+      const explainAt = stepText.indexOf(
+        `**${theory ? '从这里理解原因' : '刚才发生了什么'}**`,
+      );
+      assert(
+        actionAt >= 0 && expectedAt > actionAt && explainAt > expectedAt,
+        `${label}/step-${stepIndex + 1}: action → observation → explanation broken`,
+      );
+      assert(
+        stepText.slice(expectedAt, explainAt).includes(step.expected),
+        `${label}: expected result lost`,
+      );
+      assert(
+        stepText.slice(explainAt).includes(step.explanation),
+        `${label}: explanation lost`,
+      );
+      if (step.code) {
+        const codeAt = stepText.indexOf(step.code, actionAt + step.body.length);
+        assert(
+          codeAt > actionAt && codeAt < expectedAt,
+          `${label}: code must precede observation`,
+        );
+      }
+      cursor = until;
+      stepsChecked++;
+    }
+    for (const concept of lesson.concepts) {
+      assert(
+        lessonText.slice(positions[5], positions[6]).includes(concept.body),
+        `${label}: concept recap lost`,
+      );
+    }
+    lessonsChecked++;
+  }
+}
+console.log(
+  `Self-study downloads: ${lessonsChecked} lessons and ${stepsChecked} steps preserve preparation, action, observation, explanation and final recap.`,
+);

+ 135 - 0
scripts/review-content.mjs

@@ -0,0 +1,135 @@
+export const reviewModules = [
+  'api',
+  'database',
+  'api-database',
+  'tasks',
+  'redis',
+  'docker',
+  'linux',
+  'troubleshooting',
+  'distributed',
+];
+const nonempty = (value) =>
+  typeof value === 'string' && value.trim().length > 0;
+const strings = (value, min) =>
+  Array.isArray(value) && value.length >= min && value.every(nonempty);
+
+export function verifyReview(lesson, index, sources) {
+  const errors = [];
+  const at = `review/${lesson?.id || index + 1}`;
+  const fail = (message) => errors.push(`${at}: ${message}`);
+  if (
+    lesson.kind !== 'review' ||
+    lesson.id !== String(index + 1).padStart(2, '0') ||
+    lesson.sourceModule !== reviewModules[index]
+  )
+    fail('invalid review identity or source module');
+  for (const key of ['title', 'summary', 'group'])
+    if (!nonempty(lesson[key])) fail(`missing ${key}`);
+  if (
+    !nonempty(lesson.intro?.scenario) ||
+    !nonempty(lesson.intro?.outcome) ||
+    !strings(lesson.intro?.startingPoint, 2)
+  )
+    fail('missing self-test preparation');
+  for (const key of [
+    'steps',
+    'production',
+    'interview',
+    'demo',
+    'code',
+    'concepts',
+  ])
+    if (key in lesson)
+      fail(`review must not contain teaching-only field ${key}`);
+  const source = sources.get(lesson.sourceModule);
+  if (!source) fail('missing source lessons');
+  for (const [key, count, prefix] of [
+    ['questions', 6, 'q'],
+    ['challenges', 2, 'c'],
+  ]) {
+    if (!Array.isArray(lesson[key]) || lesson[key].length !== count) {
+      fail(`expected ${count} ${key}`);
+      continue;
+    }
+    for (const [i, item] of lesson[key].entries()) {
+      if (item.id !== `${prefix}${i + 1}`) fail(`invalid ${key} ID`);
+      if (
+        !nonempty(item.title) ||
+        !nonempty(item.prompt) ||
+        !strings(item.checkpoints, 3) ||
+        !nonempty(item.misconception)
+      )
+        fail(`incomplete ${item.id}`);
+      if (
+        !Array.isArray(item.revisit) ||
+        item.revisit.length < 1 ||
+        item.revisit.length > 2
+      ) {
+        fail(`${item.id} needs one or two return links`);
+        continue;
+      }
+      const seen = new Set();
+      for (const link of item.revisit) {
+        const original = source?.find((entry) => entry.id === link.lessonId);
+        if (
+          !original ||
+          original.title !== link.label ||
+          seen.has(link.lessonId)
+        )
+          fail(`${item.id}: invalid or duplicate return link ${link.lessonId}`);
+        seen.add(link.lessonId);
+      }
+    }
+  }
+  if (!Array.isArray(lesson.mastery) || lesson.mastery.length !== 4)
+    fail('expected four mastery criteria');
+  else
+    lesson.mastery.forEach((item, i) => {
+      if (
+        item.id !== `m${i + 1}` ||
+        !nonempty(item.text) ||
+        !nonempty(item.evidence) ||
+        item.text === item.evidence
+      )
+        fail(`incomplete mastery evidence ${item.id}`);
+    });
+  return errors;
+}
+
+const bullets = (items) => items.map((item) => `- ${item}`).join('\n');
+function links(lesson, item) {
+  return item.revisit
+    .map((link) => {
+      const route =
+        lesson.sourceModule === 'api'
+          ? `lesson=${link.lessonId}`
+          : `module=${lesson.sourceModule}&lesson=${link.lessonId}`;
+      return `[${link.lessonId} · ${link.label}](../?${route})`;
+    })
+    .join(';');
+}
+export function reviewMarkdown(lessons) {
+  let result =
+    '# 复盘与自测\n\n术核 AI · 后端工程课件\n\n每个单元对应一个已学模块。先独立回答,再看文末参考要点;合上答案后仍能解释原因、推演条件变化,并拿出四项掌握证据,才达到本轮标准。记录反映学员自评,不自动判定文字答案的正确性。\n\n';
+  for (const lesson of lessons) {
+    result += `## ${lesson.id} ${lesson.title}\n\n${lesson.summary}\n\n### 如何自测\n\n${lesson.intro.scenario}\n\n${bullets(lesson.intro.startingPoint)}\n\n本轮目标:${lesson.intro.outcome}\n\n原模块讲义:[${lesson.sourceModule}-lessons.md](./${lesson.sourceModule}-lessons.md)。离线阅读时按题目下的课号查找。\n\n`;
+    for (const [title, items] of [
+      ['核心理解问题', lesson.questions],
+      ['情境推演', lesson.challenges],
+    ]) {
+      result += `### ${title}\n\n先写下判断、原因与验证方式,再对照本单元末尾的参考要点。\n\n`;
+      for (const item of items)
+        result += `#### ${item.id.toUpperCase()} ${item.title}\n\n${item.prompt}\n\n我的回答:\n\n自评:□ 还需要回学 □ 合上要点后能独立回答并举例或推演\n\n需要时回学:${links(lesson, item)}\n\n`;
+    }
+    result += '### 掌握标准与证据\n\n';
+    for (const item of lesson.mastery)
+      result += `- [ ] ${item.text}\n\n  证据:${item.evidence}\n\n`;
+    result +=
+      '### 本轮结果与回学计划\n\n八项问题均能独立回答,且四项证据均已核对,才达到本轮标准。未完成的题按回学链接补课,再闭卷重答;已经完成的题,隔一天换个条件再次推演。\n\n需要回学的题号:\n\n尚缺的证据:\n\n下次复测时准备改变的条件:\n\n### 参考要点与易错点\n\n完成自己的回答后再阅读。表述可以不同,要核对的是推理、边界和验证依据。\n\n';
+    for (const item of [...lesson.questions, ...lesson.challenges])
+      result += `#### ${item.id.toUpperCase()} ${item.title}\n\n${bullets(item.checkpoints)}\n\n容易误判:${item.misconception}\n\n`;
+    result += '---\n\n';
+  }
+  return result;
+}

+ 18 - 3
scripts/test-course-navigation.mjs

@@ -14,6 +14,8 @@ const slugs = [
   'docker',
   'linux',
   'troubleshooting',
+  'distributed',
+  'review',
 ];
 const urls = new Set();
 for (const slug of slugs) {
@@ -25,8 +27,21 @@ for (const slug of slugs) {
     lesson: undefined,
   });
 }
-assert.equal(urls.size, 8, 'same lesson IDs must not collide across modules');
-assert.deepEqual(parseCourseQuery(''), { module: 'api', lesson: undefined });
+assert.equal(urls.size, 10, 'same lesson IDs must not collide across modules');
+assert.deepEqual(parseCourseQuery(''), { module: 'home', lesson: undefined });
+assert.deepEqual(parseCourseQuery(courseHref('home')), {
+  module: 'home',
+  lesson: undefined,
+});
+assert.equal(courseHref('home'), '?');
+assert.deepEqual(parseCourseQuery('?module=api'), {
+  module: 'api',
+  lesson: undefined,
+});
+assert.deepEqual(parseCourseQuery('?module=home&lesson=01'), {
+  module: 'home',
+  lesson: '01',
+});
 assert.deepEqual(parseCourseQuery('?module=database&lesson=99'), {
   module: 'database',
   lesson: '99',
@@ -36,5 +51,5 @@ assert.equal(
   '?module=tasks&lesson=01%26module%3Dapi',
 );
 console.log(
-  'Course navigation: legacy links, 8 module round-trips, overview routes and query encoding passed.',
+  'Course navigation: homepage, legacy links, 10 module round-trips, overview routes and query encoding passed.',
 );

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor