# M-AI-06 ReviewCard Child Job 迁移契约

> 审计日期：2026-06-21
> 契约状态：冻结（待 M-AI-06-01 验收）
> 对应里程碑：M-AI-06 Feynman ReviewCard 子 Job 与可靠生成

---

## 目录

1. [当前链路审计](#1-当前链路审计)
2. [目标链路](#2-目标链路)
3. [Parent/Child 状态机](#3-parentchild-状态机)
4. [Child Snapshot Schema](#4-child-snapshot-schema)
5. [Child Output Schema](#5-child-output-schema)
6. [幂等契约](#6-幂等契约)
7. [Feature Flag](#7-feature-flag)
8. [失败隔离](#8-失败隔离)
9. [回滚流程](#9-回滚流程)
10. [实现约束与不确定项](#10-实现约束与不确定项)

---

## 1. 当前链路审计

### 1.1 当前时序（Legacy EventBus 路径）

```text
┌─ AiAnalysisWorker ─────────────────────────────────────────────────┐
│  process(job)                                                       │
│  ├─ feynmanWorkflow.execute()          ← 调用模型（Feynman 评估）   │
│  │  `src/workers/ai-analysis.worker.ts:52-57`                       │
│  ├─ repository.createResult(userId, jobId, result)                  │
│  │  `ai-analysis.worker.ts:67`                                      │
│  ├─ repository.updateJobStatus(jobId, 'completed')                  │
│  │  `ai-analysis.worker.ts:68`                                      │
│  └─ eventBus?.publish(AIAnalysisCompleted({...}))  ← 进程内发射     │
│     `ai-analysis.worker.ts:72-81`                                   │
│                                                                     │
│  ★ 异常处理：publish 包在 try/catch 中，异常被吞（line 82）        │
│  ★ eventBus 是 @Optional()，缺失时静默跳过（line 26）               │
└──────────────────────┬──────────────────────────────────────────────┘
                       │ EventEmitter2.emit('ai.analysis.completed')
                       │ `src/common/event-bus/event-bus.service.ts:22`
                       ▼
┌─ ReviewCardSubscriber ──────────────────────────────────────────────┐
│  @OnEvent('ai.analysis.completed')                                  │
│  handleAIAnalysisCompleted(payload)                                 │
│  `src/modules/review/review-card.subscriber.ts:12`                  │
│  ├─ 提取 weaknesses / strengths / summary                          │
│  ├─ cardCount = Math.min(3, Math.max(1, weaknesses.length || 1))   │
│  │  `review-card.subscriber.ts:42`                                  │
│  └─ reviewService.generateCards(userId, {title, content, cardCount})│
│     `review-card.subscriber.ts:39-43`                               │
│                                                                     │
│  ★ 异常被 catch 吞掉，不向上传播（line 48-49）                     │
│  ★ 无幂等检查，每次事件都生成卡片                                   │
└──────────────────────┬──────────────────────────────────────────────┘
                       │
                       ▼
┌─ ReviewService.generateCards ───────────────────────────────────────┐
│  `src/modules/review/review.service.ts:68`                          │
│  ├─ cardGenerationWorkflow.execute(input)  ← 第二次 AI 调用        │
│  │  `review.service.ts:73-78`                                       │
│  └─ for each card: reviewRepository.insertCard({...})              │
│     `review.service.ts:82-95`                                       │
│     ★ 每次 insertCard 是独立的 Prisma create()
│     ★ 无事务包裹
│     ★ 部分插入失败 → 部分卡片残留
└──────────────────────┬──────────────────────────────────────────────┘
                       │
                       ▼
┌─ ReviewCardGenerationWorkflow.execute ──────────────────────────────┐
│  `src/modules/ai/workflows/review-card-generation.workflow.ts:17`   │
│  ├─ 构造 userMessage（知识点标题 + 内容 + cardCount）              │
│  ├─ gateway.generate({                                             │
│  │    feature: 'review-card-generation',                            │
│  │    tier: 'cheap',                                                │
│  │    promptKey: 'review-card-generation',                          │
│  │    promptVersion: '1.0.0',                                       │
│  │    outputSchema: ReviewCardGenerationResultSchema,               │
│  │  })                                                              │
│  │  `review-card-generation.workflow.ts:30-40`                     │
│  └─ return response.parsed                                          │
└──────────────────────┬──────────────────────────────────────────────┘
                       │
                       ▼
┌─ ReviewRepository.insertCard ───────────────────────────────────────┐
│  `src/modules/review/review.repository.ts:24-37`                    │
│  prisma.reviewCard.create({ data })                                 │
│  ★ ID 由 Prisma @default(cuid()) 自动生成，不可显式赋值            │
│  ★ 无 jobId / sourceId / dedupeKey 字段                            │
│  ★ 无唯一约束（除 PK 外）                                          │
└─────────────────────────────────────────────────────────────────────┘
```

### 1.2 EventBus 行为确认

| 属性 | 结论 | 证据 |
|------|------|------|
| 进程内/持久化 | **进程内**（EventEmitter2，非持久化） | `src/common/event-bus/event-bus.service.ts:19-23` — 调用 `this.eventEmitter.emit()` |
| publish 是否 await | **同步 fire-and-forget** | `event-bus.service.ts:19` — 返回 `void`，非 Promise |
| Subscriber 是否同步 | **同步**（同一 EventEmitter 实例） | `@OnEvent` 装饰器由 NestJS EventEmitterModule 同步调用 |
| 异常是否传播 | **不传播** — Worker 层 catch 吞掉 | `ai-analysis.worker.ts:82` — `} catch {}` 空块 |
| Subscriber 异常 | **被吞掉** | `review-card.subscriber.ts:48-49` — `catch (err) { logger.error(...) }` |
| 进程崩溃是否丢失 | **是** — 事件仅存于内存 | 无持久化机制（对比 `publishAsync` 使用 BullMQ） |
| 重复 publish 去重 | **无去重** | Subscriber 无幂等检查 |
| Worker 重启后重放 | **否** — 事件不持久化 | BullMQ 只负责触发 Worker，Worker 内部事件发射是临时的 |

### 1.3 ReviewCard 输入字段分类

| 字段 | 来源 | 分类 |
|------|------|------|
| `userId` | `payload.userId` | **进入 Child Snapshot** |
| `knowledgeItemTitle` | `payload.analysis.summary.slice(0, 80)` 或 `'AI 分析结果'` | **进入 Child Snapshot** |
| `knowledgeItemContent` | 拼接 `summary + strengths + weaknesses` | **进入 Child Snapshot** |
| `cardCount` | `Math.min(3, Math.max(1, weaknesses.length &#124;&#124; 1))` | **进入 Child Snapshot** |
| `weaknesses` | `payload.analysis.weaknesses` | **进入 Child Snapshot**（用于 cardCount） |
| `strengths` | `payload.analysis.strengths` | **进入 Child Snapshot**（用于 content） |
| `summary` | `payload.analysis.summary` | **进入 Child Snapshot**（用于 title + content） |
| `promptKey` | 硬编码 `'review-card-generation'` | **进入 Child Snapshot** |
| `promptVersion` | 硬编码 `'1.0.0'` | **进入 Child Snapshot** |
| `modelTier` | 硬编码 `'cheap'` | **进入 Child Snapshot** |
| `outputSchema` | `ReviewCardGenerationResultSchema` | **进入 Child Snapshot**（schema 版本号） |
| `jobId` | `payload.jobId` | **不进入 Snapshot**（通过 parentJobId 关联） |
| `sessionId` | `payload.sessionId` | **不进入 Snapshot**（与卡片生成无关） |
| `answerId` | `payload.answerId` | **不进入 Snapshot**（与卡片生成无关） |
| JWT / API Key | — | **禁止进入 Snapshot** |

### 1.4 卡片数量规则

**代码证据**：`src/modules/review/review-card.subscriber.ts:42`

```ts
cardCount: Math.min(3, Math.max(1, (a.weaknesses?.length || 1)))
```

- 无 weaknesses：1 张
- 1 个 weakness：1 张
- 2 个 weaknesses：2 张
- 3+ weaknesses：3 张
- **范围：[1, 3]**

与需求文档中的 `min(3, max(1, weaknesses.length))` **一致**（`|| 1` 处理空数组/undefined）。

### 1.5 ReviewCard 写入字段

| 字段 | 写入值 | 来源 |
|------|--------|------|
| `id` | `cuid()` 自动生成 | Prisma `@default(cuid())` — `schema.prisma:732` |
| `userId` | `input.userId` | `review.service.ts:83` |
| `knowledgeItemId` | `undefined` | **未设置**（`insertCard` 调用未传此字段 — `review.service.ts:82-94`） |
| `frontText` | `card.frontText` | AI 输出 — `review.service.ts:84` |
| `backText` | `card.backText` | AI 输出 — `review.service.ts:85` |
| `difficulty` | `card.difficulty` | AI 输出 — `review.service.ts:86` |
| `status` | `'active'` | 硬编码 — `review.service.ts:87` |
| `intervalDays` | `1` | 硬编码 — `review.service.ts:88` |
| `easeFactor` | `2.5` | 硬编码 — `review.service.ts:89` |
| `repetitionCount` | `0` | 硬编码 — `review.service.ts:90` |
| `lapseCount` | `0` | 硬编码 — `review.service.ts:91` |
| `scheduleState` | `'new'` | 硬编码 — `review.service.ts:92` |
| `nextReviewAt` | `new Date()` | 硬编码 — `review.service.ts:93` |
| `focusItemId` | — | **未设置** |
| `createdAt` | `now()` | Prisma `@default(now())` |
| `updatedAt` | `now()` | Prisma `@updatedAt` |

**★ ReviewCard 不支持显式 ID 赋值**：`schema.prisma:732` — `id String @id @default(cuid())`，无法通过 `create({ id: ... })` 显式指定。

### 1.6 幂等能力现状

| 检查项 | 结论 |
|--------|------|
| ReviewCard 表有 `jobId`？ | **否** |
| ReviewCard 表有 `sourceId`？ | **否** |
| ReviewCard 表有 `externalId`？ | **否** |
| ReviewCard 表有 `dedupeKey`？ | **否** |
| 允许 deterministic ID？ | **否** — CUID auto-generated |
| 有可利用的唯一约束？ | **否** — 仅 PK `id` |
| 重复 EventBus 当前生成重复卡片？ | **是** — 无任何去重机制 |

### 1.7 Parent/Child 数据模型

**AiJob 表已支持**：

| 字段 | 类型 | 用途 | 证据 |
|------|------|------|------|
| `parentJobId` | `String? @db.VarChar(255)` | 指向父 Job | `schema.prisma:580` |
| `idempotencyKey` | `String? @db.VarChar(255)` | 幂等去重键 | `schema.prisma:582` |
| `children` | `AiJob[]` | 子 Job 列表（自引用） | `schema.prisma:619` |
| `@@unique([userId, jobType, idempotencyKey])` | | 幂等约束 | `schema.prisma:636` |
| `@@index([parentJobId])` | | 子 Job 查询索引 | `schema.prisma:627` |

**Snapshot 关联**：
- `AiJobSnapshot.jobId` 是 `@unique`，与 AiJob 一对一（`schema.prisma:648`）
- AiJob 侧无 `snapshotId` 字段，通过 `AiJobSnapshot.jobId` 反向查询

**删除策略**：
- `AiJobArtifact`：`onDelete: Cascade`（`schema.prisma:672`）
- `AiJobSnapshot`：`onDelete: Cascade`（`schema.prisma:658`）
- `OutboxEvent`：无 Cascade（独立生命周期）

### 1.8 事务创建能力

| 接口 | 支持外部 tx？ | 证据 |
|------|--------------|------|
| `AiJobCreationService.createJob()` | **否** — 内部 `$transaction` | `ai-job-creation.service.ts:158` — `this.prisma.$transaction(createFn)` |
| `AiJobLifecycleRepository.createJob()` | **是** — 要求 `tx` 参数 | `ai-job-lifecycle.repository.ts:80` — `tx: Prisma.TransactionClient` |
| `AiJobLifecycleRepository.markSucceeded()` | **是** — 可选 `tx` 参数 | `ai-job-lifecycle.repository.ts:228` |
| `OutboxRepository.createInTransaction()` | **是** — 要求 `tx` 参数 | `outbox.repository.ts:37` |
| `AiJobSnapshot`（直接 Prisma） | **是** — 由 `AiJobCreationService` 直接调用 `tx.aiJobSnapshot.create()` | `ai-job-creation.service.ts:132` |

**关键结论**：`AiJobCreationService` 需要新增 `createInTransaction(tx, input)` 方法，复用内部逻辑但不自行开启事务。`AiJobLifecycleRepository.createJob()` 和 `OutboxRepository.createInTransaction()` 已具备外部事务能力。

### 1.9 失败语义（当前状态）

| 场景 | 旧 AiAnalysis Job 状态 | ReviewCard |
|------|----------------------|------------|
| Worker 崩溃前 publish 成功 | `completed` | 已生成 ✅ |
| Worker 崩溃前 publish 未执行 | `completed`（结果已写入） | **丢失** ❌ |
| EventBus.emit 执行中崩溃 | `completed` | **部分生成** ❌ |
| Subscriber 异常 | `completed` | **丢失** ❌ |
| 模型调用失败 | `completed` | **丢失** ❌ |
| 部分 insertCard 失败 | `completed` | **部分残留** ❌ |
| 重复 Worker 执行 | 旧 job 保持 completed | **重复卡片** ❌ |

---

## 2. 目标链路

### 2.1 目标时序（Child Job 路径）

```text
┌─ POST /api/ai-analysis/feynman ─────────────────────────────────────┐
│  FeynmanExecutionRouter.evaluateFeynman(userId, input)              │
│  `src/modules/ai-analysis/feynman-execution-router.ts:70`           │
│  ├─ FeatureFlag: FEYNMAN_REVIEW_CARD_MODE === 'child_job' ?        │
│  ├─ [legacy_event] → AiAnalysisService.evaluateFeynman()           │
│  └─ [child_job]    → Unified 路径 ↓                                │
└──────────────────────────┬──────────────────────────────────────────┘
                           │
                           ▼
┌─ FeynmanExecutionRouter (Unified 路径) ─────────────────────────────┐
│  ├─ FeynmanSnapshotBuilder.build(input)  ← 构建 Parent Snapshot    │
│  └─ AiJobCreationService.createJob({...})                           │
│     ★ 触发 Parent Job                                               │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ ai-interactive → Outbox Dispatcher → BullMQ
                           ▼
┌─ Worker: AiJobExecutionEngine ──────────────────────────────────────┐
│  ├─ RESOLVE: FeynmanExecutor.execute(snapshot) ← AI 调用           │
│  ├─ VALIDATE: FeynmanBusinessValidator                             │
│  └─ PROJECT: FeynmanProjector.project(tx, ctx)                     │
│     ─── 同一 Prisma 事务内 ───                                      │
│     ├─ AiAnalysisResult (deterministic ID: fe_<jobId>)              │
│     ├─ FocusItem (每 weakness 一个)                                 │
│     ├─ Parent AiJobArtifact                                        │
│     ├─ ★ ReviewCard Child Job（createInTransaction）                │
│     ├─ ★ Child Snapshot                                            │
│     ├─ ★ Child OutboxEvent                                         │
│     └─ Parent Job → succeeded                                      │
│     ─── 事务提交 ───                                                │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ Outbox Dispatcher → BullMQ
                           │ Queue: ai-background
                           ▼
┌─ Worker: AiJobExecutionEngine (Child Job) ──────────────────────────┐
│  ├─ RESOLVE: ReviewCardGenerationExecutor.execute(childSnapshot)   │
│  │  ★ 通过 AiGateway，保持 cheap tier + review-card-generation prompt│
│  ├─ VALIDATE: ReviewCardGenerationValidator                        │
│  └─ PROJECT: ReviewCardGenerationProjector.project(tx, ctx)        │
│     ─── 同一 Prisma 事务内 ───                                      │
│     ├─ ReviewCard × N (deterministic ID 或 DB 级幂等)              │
│     ├─ AiJobArtifact × N                                           │
│     └─ Child Job → succeeded                                       │
│     ─── 事务提交 ───                                                │
└─────────────────────────────────────────────────────────────────────┘
```

### 2.2 关键差异对比

| 维度 | Legacy EventBus | Child Job |
|------|----------------|-----------|
| 触发方式 | `EventEmitter2.emit()`（进程内） | OutboxEvent → BullMQ（持久化） |
| 事务保证 | 无（事件在事务外） | 与 Parent Projector 同一事务 |
| 失败隔离 | Subscriber 异常吞掉 | Child Job 独立 failed 状态 |
| 重试机制 | 无 | BullMQ retry + Admin 手动重试 |
| 卡片幂等 | 无 | deterministic ID 或 DB 级唯一约束 |
| 可观测性 | 仅日志 | AiJob 状态机 + Admin 查询 |
| 双执行防护 | 无 | Feature Flag 互斥 |

---

## 3. Parent/Child 状态机

### 3.1 Parent Job（Feynman）状态

```
pending → queued → running → succeeded
                              ↓ (Child Job 通过 Child Outbox 异步执行)
                              ★ Parent 不等待 Child 完成
                              ★ Parent 成功与 Child 状态无关
```

### 3.2 Child Job（ReviewCard Generation）状态

```
queued → running → succeeded   ← Child 正常完成
                 → failed      ← Child 异常（可独立重试）
                 → cancelled   ← Admin 取消
```

### 3.3 失败语义冻结

| 场景 | Parent 状态 | Child 状态 | ReviewCard | 说明 |
|------|------------|-----------|------------|------|
| Parent Projector 失败 | `failed` | 不存在 | 不存在 | Child 创建属于 Parent 事务，整体回滚 |
| Child 创建失败（事务内） | `failed`（回滚） | 不存在 | 不存在 | P2002 冲突或 DB 错误导致 Parent 事务失败 |
| Child Executor 失败 | `succeeded` | `failed` | 不存在 | **Parent 不回滚** |
| Child Projector 失败 | `succeeded` | `failed` | 不存在 | Child Projector 事务回滚，无部分卡片 |
| Child 重试成功 | `succeeded` | `succeeded` | 已生成 | Admin 手动重试或自动重试 |
| Child 永久失败 | `succeeded` | `failed` | 不存在 | 合法状态，需运维介入 |

**核心原则：Parent succeeded + Child failed 是合法最终状态。禁止因附属 ReviewCard 失败回滚已成功的 Feynman 评估。**

### 3.4 状态转换约束

- Child Job **不得**修改 Parent 的 `lifecycleStatus`、`finishedAt`
- Child Job **不得**修改 Parent 的 `validatedOutput`
- Parent Job 取消时，**应**级联取消未完成的 Child Job
- Child Job 重试**不得**重新创建 Parent 的 Result/FocusItem

---

## 4. Child Snapshot Schema

### 4.1 必须包含

```typescript
interface ReviewCardGenerationSnapshot {
  /** 用户 ID（从 Parent Job 继承） */
  userId: string;
  /** 父 Job ID */
  parentJobId: string;
  /** 来源 Feynman 评估结果 ID（fe_<parentJobId>） */
  sourceResultId: string;
  /** 知识点 ID（从 Parent Snapshot 读取） */
  knowledgeItemId: string;
  /** 知识库 ID（从 Parent Snapshot 读取） */
  knowledgeBaseId: string;
  
  /** 评估摘要（来自 Feynman validatedOutput.summary，截断前 80 字符用于 title） */
  title: string;
  /** 拼接摘要 text（summary + strengths + weaknesses） */
  summary: string;
  /** 薄弱项列表 */
  strengths: string[];
  /** 掌握项列表 */
  weaknesses: string[];
  
  /** 目标卡片数量（规则：Math.min(3, Math.max(1, weaknesses.length || 1))） */
  cardCount: number;
  
  /** 冻结的 Prompt 配置 */
  promptKey: 'review-card-generation';
  promptVersion: '1.0.0';
  
  /** 模型层级（冻结为 cheap） */
  modelTier: 'cheap';
  
  /** Schema 版本 */
  inputSchemaVersion: string;
  outputSchemaVersion: string;
}
```

### 4.2 禁止进入 Snapshot

- JWT / Authorization Header
- 模型 API Key / Provider Credential
- 数据库连接信息
- 完整用户画像（除 userId 外的 PII）
- 完整 Parent Job 数据库实体（仅引用 parentJobId）
- 原始 Parent 模型响应（仅引用 sourceResultId）
- 内部错误堆栈信息
- `sessionId`、`answerId`（与卡片生成无关）

### 4.3 稳定性要求

- 相同 `parentJobId` → 相同 `contentHash`
- `cardCount` 必须基于 `weaknesses.length` 确定计算，非随机
- `idempotencyKey` 必须稳定（不包含时间戳或随机值）
- `contentHash` = `sha256(JSON.stringify(snapshotContent)).substring(0, 16)` — 与现有 `AiJobCreationService.computeHash()` 一致

---

## 5. Child Output Schema

### 5.1 AI 输出 Schema（冻结）

```typescript
// 与现有 ReviewCardGenerationResultSchema 完全一致
// src/modules/ai/prompts/schemas/review-card-generation.schema.ts

{
  cards: Array<{
    frontText: string;  // 1-500 字符
    backText: string;   // 1-1000 字符
    difficulty: 'easy' | 'medium' | 'hard';
    tags: string[];     // 每标签 max 50 字符，最多 5 个
  }>;
  totalCount: number;   // 1-20
}
```

### 5.2 验证规则

| 规则 | 严重程度 | 违规处理 |
|------|---------|---------|
| `cards.length > 0` | P0 | 拒绝，Job failed |
| `cards.length <= 20` | P0 | 拒绝，Job failed |
| `cards.length` 与 `totalCount` 一致 | P1 | 拒绝，Job failed |
| `cards.length` 与 `cardCount` 一致或在 `[cardCount-1, cardCount+1]` 允许范围 | P2 | 超出范围 → 裁剪或拒绝 |
| `frontText` 非空且 ≤ 500 | P0 | 拒绝该卡片 |
| `backText` 非空且 ≤ 1000 | P0 | 拒绝该卡片 |
| `difficulty` ∈ `{easy, medium, hard}` | P1 | 默认 `medium` |
| 禁止空卡片（frontText 和 backText 都为空） | P0 | 拒绝 |
| 禁止完全重复卡片（frontText + backText 相同） | P1 | 去重 |
| 禁止模型指令文本（如"请分析…"、"根据上文…"） | P0 | 拒绝 |
| 禁止 Markdown 代码块标记（如 "```json"） | P0 | 拒绝 |
| 禁止超大文本（frontText > 2000 或 backText > 5000） | P0 | 拒绝 |

### 5.3 Business Validator

新增 `ReviewCardGenerationValidator`：
- 实现 `BusinessValidator` 接口
- 不得重新设计卡片质量算法
- 仅执行上述规则的结构化验证

---

## 6. 幂等契约

### 6.1 Child Job 幂等

```text
idempotencyKey = "review-card:feynman:<parentJobId>"
```

- 唯一约束：`AiJob.@@unique([userId, jobType, idempotencyKey])`
- 一个 Parent Job 最多一个 ReviewCard Child Job
- 重复执行 Parent Projector 时：P2002 catch → 返回已有 Child Job
- 不创建第二个 Child Snapshot
- 不创建第二个 Child Outbox

### 6.2 ReviewCard 幂等

**关键约束**：ReviewCard 的 `id` 使用 `@default(cuid())` 不允许显式赋值。

**方案 A（推荐）**：在 `ReviewCard` 表上新增 `@@unique([childJobId, ordinal])`。

需要 M-AI-06-02 中通过 Prisma Migration 新增：
```prisma
model ReviewCard {
  // ... existing fields ...
  childJobId String? @db.VarChar(255)  // 新增
  ordinal     Int     @default(0)      // 新增

  @@unique([childJobId, ordinal])       // 新增
}
```

Child Projector 写入时：
```ts
for (const [idx, card] of result.cards.entries()) {
  try {
    await tx.reviewCard.create({
      data: {
        childJobId: job.id,
        ordinal: idx,
        // ... other fields
      },
    });
  } catch (err) {
    if (err?.code === 'P2002') continue; // 幂等跳过
    throw err;
  }
}
```

**方案 B（备选）**：使用 `AiJobArtifact` 唯一约束间接保证。

利用现有 `@@unique([jobId, artifactType, artifactId])`：
```ts
await upsertArtifact(tx, childJobId, 'ReviewCard', reviewCardId, ordinal);
```
但 `reviewCardId` 无法预测（CUID 自动生成），无法作为稳定的 `artifactId`。

**★ 最终选择需在 M-AI-06-05 实现时确定，取决于是否允许新增 Migration。**

### 6.3 禁止的幂等方案

- ❌ `findFirst({ frontText }) → create` — 不同卡片可能相同题面，并发不安全
- ❌ 全局 frontText 去重 — 违反业务语义（同一用户可有多张同题面卡片）
- ❌ 仅在内存中去重 — 并发不安全
- ❌ 依赖 `insertCard` 返回的 CUID 做幂等 — 每次调用生成不同 ID

---

## 7. Feature Flag

### 7.1 定义

```text
Flag Name: FEYNMAN_REVIEW_CARD_MODE
Values:    legacy_event | child_job
Default:   legacy_event
```

### 7.2 行为

| 模式 | Feynman Parent 完成后 | ReviewCard 生成 |
|------|----------------------|----------------|
| `legacy_event` | `eventBus.publish('ai.analysis.completed')` | `ReviewCardSubscriber` → 二次 AI 调用 |
| `child_job` | 事务内创建 `review_card_generation` Child Job | Child Job Worker → 二次 AI 调用 |

### 7.3 互斥要求

- `child_job` 模式下：**不发布** `ai.analysis.completed`（Legacy 事件），或事件携带 `reviewCardMode: 'child_job'` 标识使 `ReviewCardSubscriber` 忽略
- 同一个 Parent Job 只能选择一种 ReviewCard 生成方式
- 切回 `legacy_event`：新 Parent 使用旧事件路径；已创建的 Child Job 继续运行；不删除已生成 ReviewCard
- `legacy_event` 模式用于**回滚**，确保可快速切回旧行为

---

## 8. 失败隔离

### 8.1 隔离边界

```text
┌── Parent Transaction ──────────────────────┐
│  FeynmanResult + FocusItem + ParentArtifact │
│  + Child Job + Child Snapshot + Child Outbox│
│  → COMMIT                                    │
│  失败 → 全部 ROLLBACK                        │
└─────────────────────────────────────────────┘
                    │
                    │ Outbox → BullMQ → Worker
                    ▼
┌── Child Transaction ───────────────────────┐
│  ReviewCard × N + Artifact × N              │
│  → COMMIT                                    │
│  失败 → 全部 ROLLBACK（Parent 不受影响）     │
└─────────────────────────────────────────────┘
```

### 8.2 恢复操作

| 场景 | 操作 |
|------|------|
| Child Job `failed` | Admin 重试 Child Job（`AiJobService.retryJob`） |
| Child Job 永久失败 | 运维介入，手动检查日志 |
| Parent Projector 重新执行 | 返回已有 Child Job（幂等），不重复创建 |
| Child Worker 崩溃 | BullMQ 自动重试（根据 Definition.maxRetries） |

---

## 9. 回滚流程

### 9.1 child_job → legacy_event

```text
1. 修改 FeatureFlag: FEYNMAN_REVIEW_CARD_MODE = legacy_event
2. 生效后：
   - 新 Feynman 请求走 Legacy EventBus
   - ReviewCardSubscriber 正常处理 ai.analysis.completed
   - 已创建的 Child Job 继续完成
   - 已生成 ReviewCard 保留
3. 无需数据库回滚
4. 无需删除 Child Job
```

### 9.2 legacy_event → child_job（灰度上线）

```text
1. 部署包含 Child Job 支持的代码
2. 对测试用户/白名单开启 FEYNMAN_REVIEW_CARD_MODE = child_job
3. 监控 Child Job 成功率、卡片生成量
4. 逐步扩大白名单
5. 全量切换为 child_job
```

---

## 10. 实现约束与不确定项

### 10.1 确定项

| 项 | 结论 |
|----|------|
| EventBus 类型 | 进程内 EventEmitter2，不做持久化 |
| promptKey | `review-card-generation` |
| promptVersion | `1.0.0` |
| modelTier | `cheap` |
| queueName | `ai-background` |
| triggerType | `child_job` |
| cardCount 规则 | `Math.min(3, Math.max(1, weaknesses.length \|\| 1))` |
| ReviewCard 默认 SM-2 值 | status=active, interval=1, easeFactor=2.5, rep=0, lapse=0, state=new, next=now |
| Parent Projector 不创建 ReviewCard | 确认（`feynman-projector.ts:178-180`） |
| Child Executor 不写数据库 | 与现有 Executor 模式一致 |
| Child Projector 在事务内 | 与现有 Projector 模式一致 |

### 10.2 不确定项（需 M-AI-06-02 ~ M-AI-06-05 实现时解决）

| 项 | 问题 | 建议 |
|----|------|------|
| ReviewCard 幂等方案 | CUID 不支持显式 ID，需选择方案 A 或 B | 优先方案 A（新增 `@@unique([childJobId, ordinal])`），需评估 Migration 风险 |
| `createInTransaction` 实现 | `AiJobCreationService` 需新增事务方法 | 复用现有 `createJob` 内部逻辑，提取可注入 `tx` 的核心方法 |
| P2002 后安全读取 | Prisma/MySQL 事务内 P2002 后能否安全读取已存在的 Job | 需要集成测试验证（MySQL InnoDB 行为） |
| Legacy Subscriber 忽略 child_job | 是否修改 `ReviewCardSubscriber` 检查 Flag | 建议 Subscriber 也检查 FeatureFlag，双保险 |
| `knowledgeItemId` 在 Child Snapshot | 当前 Legacy 路径未设置 `knowledgeItemId` | 从 Parent Snapshot 读取（FeynmanProjector 已有此逻辑） |

### 10.3 非目标（明确不做）

- ❌ Quiz 生成 / 迁移
- ❌ Active Recall 的 ReviewCard 重构
- ❌ 复习算法（SM-2）升级
- ❌ EventBus 平台整体重构
- ❌ 通用消息总线建设
- ❌ ReviewCard 表大规模重构（除幂等所需最小字段）
- ❌ 删除 `ReviewCardSubscriber` / `AiAnalysisWorker` / 旧 `ReviewCardGenerationWorkflow`
- ❌ Heavy Runtime 清理
- ❌ 压力测试
- ❌ Worker SIGKILL 全场景硬化

---

## 附录 A：关键文件索引

| 用途 | 绝对路径 | 关键行号 |
|------|---------|---------|
| EventBus 服务 | `src/common/event-bus/event-bus.service.ts` | `:19` publish, `:26` publishAsync |
| EventBus 模块 | `src/common/event-bus/event-bus.module.ts` | `:1` @Global() |
| AiAnalysisWorker | `src/workers/ai-analysis.worker.ts` | `:72` publish, `:52-57` feynman execute |
| ReviewCardSubscriber | `src/modules/review/review-card.subscriber.ts` | `:12` handle, `:42` cardCount |
| ReviewService | `src/modules/review/review.service.ts` | `:68` generateCards, `:82-94` insertCard |
| ReviewRepository | `src/modules/review/review.repository.ts` | `:24-37` insertCard |
| ReviewCardGenerationWorkflow | `src/modules/ai/workflows/review-card-generation.workflow.ts` | `:17` execute, `:30-40` gateway call |
| ReviewCard Prompt | `src/modules/ai/prompts/review-card-generation.prompt.ts` | `:1` system prompt |
| ReviewCard Output Schema | `src/modules/ai/prompts/schemas/review-card-generation.schema.ts` | `:3-13` schema |
| Prompt Template Service | `src/modules/ai/prompts/prompt-template.service.ts` | `:46-49` review-card registration |
| FeynmanProjector | `src/modules/ai-job/feynman-projector.ts` | `:38` project, `:178-180` ReviewCard NOT in projector |
| FeynmanExecutor | `src/modules/ai-job/feynman-executor.ts` | `:44` execute |
| Feynman Definition | `src/modules/ai-job/feynman-job-definition.ts` | `:18` Definition, `:59` modelTier=primary |
| Feynman Execution Router | `src/modules/ai-analysis/feynman-execution-router.ts` | `:70` evaluateFeynman, `:124` createJob |
| AiJobCreationService | `src/modules/ai-job/ai-job-creation.service.ts` | `:67` createJob, `:158` $transaction, `:164-183` P2002 |
| AiJobLifecycleRepository | `src/modules/ai-job/ai-job-lifecycle.repository.ts` | `:80` createJob, `:228` markSucceeded |
| OutboxRepository | `src/infrastructure/outbox/outbox.repository.ts` | `:37` createInTransaction |
| ActiveRecallProjector | `src/modules/ai-job/active-recall-projector.ts` | `:148-192` ReviewCard creation pattern |
| ReviewCard Prisma Model | `prisma/schema.prisma` | `:731-757` |
| AiJob Prisma Model | `prisma/schema.prisma` | `:568-639` |
| AiJobSnapshot Model | `prisma/schema.prisma` | `:643-659` |
| AiJobArtifact Model | `prisma/schema.prisma` | `:663-677` |
| OutboxEvent Model | `prisma/schema.prisma` | `:2323-2344` |

## 附录 B：Prompt 兼容性确认

| 属性 | 旧 Workflow 值 | Child Executor 要求 |
|------|---------------|-------------------|
| promptKey | `'review-card-generation'` | **相同** |
| promptVersion | `'1.0.0'` | **相同** |
| systemPrompt | `REVIEW_CARD_GENERATION_SYSTEM_PROMPT` | **相同**（来自 PromptTemplateService） |
| userMessage 格式 | 知识点标题 + 内容 + cardCount 指示 | **相同** |
| model tier | `'cheap'` | **相同** |
| outputSchema | `ReviewCardGenerationResultSchema` | **相同** |
| temperature | 默认（AiGateway 内部） | **相同** |
| maxTokens | 默认（AiGateway 内部） | **相同** |
