# 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 || 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_) │ │ ├─ 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_) */ 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:" ``` - 唯一约束:`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 内部) | **相同** |