api-server/docs/architecture/m-ai-06-review-card-child-job-contract.md
wangdl f1e529b99e
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 26s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
feat: M-AI-06 Feynman ReviewCard Child Job 与可靠生成
完成 M-AI-06 全部 7 个 Issue:
- 01: 审计冻结契约 (docs/architecture/m-ai-06-review-card-child-job-contract.md)
- 02: AiJobCreationService.createInTransaction(tx, input)
- 03: ReviewCard Generation Job Definition + Snapshot Builder
- 04: ReviewCard Generation Executor + 输出验证 Validator
- 05: ReviewCard Generation Projector + 卡片幂等 + Engine 接入
- 06: FeynmanProjector 接入 FEYNMAN_REVIEW_CARD_MODE Feature Flag
- 07: E2E 测试 (test/m-ai-06-review-card-child.e2e-spec.ts)

核心变更:
- 新增 10 个文件, 修改 7 个文件
- 新增 createInTransaction() 外部事务支持
- 新增 review_card_generation Job Type (ai-background / cheap tier)
- FeynmanProjector 根据 Feature Flag 路由 child_job / legacy_event
- Projector 入口幂等 + P2002 回退双重保障
- 测试: 单元 482 passed, E2E 待 CI 执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 19:27:14 +08:00

677 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 只负责触发 WorkerWorker 内部事件发射是临时的 |
### 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)))
```
- 无 weaknesses1 张
- 1 个 weakness1 张
- 2 个 weaknesses2 张
- 3+ weaknesses3 张
- **范围:[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 JobcreateInTransaction
│ ├─ ★ 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 JobFeynman状态
```
pending → queued → running → succeeded
↓ (Child Job 通过 Child Outbox 异步执行)
★ Parent 不等待 Child 完成
★ Parent 成功与 Child 状态无关
```
### 3.2 Child JobReviewCard 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 评估结果 IDfe_<parentJobId> */
sourceResultId: string;
/** 知识点 ID从 Parent Snapshot 读取) */
knowledgeItemId: string;
/** 知识库 ID从 Parent Snapshot 读取) */
knowledgeBaseId: string;
/** 评估摘要(来自 Feynman validatedOutput.summary截断前 80 字符用于 title */
title: string;
/** 拼接摘要 textsummary + 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 │
│ 失败 → 全部 ROLLBACKParent 不受影响) │
└─────────────────────────────────────────────┘
```
### 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` |
## 附录 BPrompt 兼容性确认
| 属性 | 旧 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 内部) | **相同** |