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

33 KiB
Raw Blame History

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

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


目录

  1. 当前链路审计
  2. 目标链路
  3. Parent/Child 状态机
  4. Child Snapshot Schema
  5. Child Output Schema
  6. 幂等契约
  7. Feature Flag
  8. 失败隔离
  9. 回滚流程
  10. 实现约束与不确定项

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-49catch (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 Snapshotschema 版本号)
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)))
  • 无 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:732id 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 反向查询

删除策略

  • AiJobArtifactonDelete: Cascadeschema.prisma:672
  • AiJobSnapshotonDelete: Cascadeschema.prisma:658
  • OutboxEvent:无 Cascade独立生命周期

1.8 事务创建能力

接口 支持外部 tx 证据
AiJobCreationService.createJob() — 内部 $transaction ai-job-creation.service.ts:158this.prisma.$transaction(createFn)
AiJobLifecycleRepository.createJob() — 要求 tx 参数 ai-job-lifecycle.repository.ts:80tx: 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 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 的 lifecycleStatusfinishedAt
  • 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 评估结果 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
  • 内部错误堆栈信息
  • sessionIdanswerId(与卡片生成无关)

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.lengthtotalCount 一致 P1 拒绝Job failed
cards.lengthcardCount 一致或在 [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.completedLegacy 事件),或事件携带 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                                    │
│  失败 → 全部 ROLLBACKParent 不受影响)     │
└─────────────────────────────────────────────┘

8.2 恢复操作

场景 操作
Child Job failed Admin 重试 Child JobAiJobService.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

附录 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 内部) 相同