完成 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>
33 KiB
M-AI-06 ReviewCard Child Job 迁移契约
审计日期:2026-06-21 契约状态:冻结(待 M-AI-06-01 验收) 对应里程碑:M-AI-06 Feynman ReviewCard 子 Job 与可靠生成
目录
- 当前链路审计
- 目标链路
- Parent/Child 状态机
- Child Snapshot Schema
- Child Output Schema
- 幂等契约
- Feature Flag
- 失败隔离
- 回滚流程
- 实现约束与不确定项
1. 当前链路审计
1.1 当前时序(Legacy EventBus 路径)
┌─ 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 || 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
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 路径)
┌─ 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 必须包含
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(冻结)
// 与现有 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 幂等
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 新增:
model ReviewCard {
// ... existing fields ...
childJobId String? @db.VarChar(255) // 新增
ordinal Int @default(0) // 新增
@@unique([childJobId, ordinal]) // 新增
}
Child Projector 写入时:
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]):
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 定义
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 隔离边界
┌── 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
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(灰度上线)
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 内部) | 相同 |