# M-AI-04 Active Recall 迁移契约 > 状态:✅ 已审计冻结(M-AI-04-01 完成) > 审计日期:2026-06-21 > 基线:M-AI-03 GATE PASS(commit `5108a9a`) > 对应 Issue:[#296](https://git.admin.longde.cloud/wangdl/api-server/issues/296) > ⚠️ 行号引用以 commit `5108a9a` 为准。后续代码变更可能导致行号漂移,Review 时请对照该 commit 验证。 --- ## 1. 当前链路(已审计确认) ``` POST /api/active-recalls/:id/submit → Controller: ActiveRecallController.submit() active-recall.controller.ts:21 @Body() body: any // ⚠️ 无 DTO 校验,期望 { answerText: string } → ActiveRecallService.submit(userId, questionId, body) active-recall.service.ts:19-39 ├── ⚠️ userId 来自 @CurrentUser() → user?.id || 'anonymous' │ JwtAuthGuard 作为全局 APP_GUARD (app.module.ts:184) 生效, │ 但代码中的 || 'anonymous' fallback 暗示防御性编程, │ 实际上 JWT 校验失败时 Guard 直接返回 401,不会到达 Controller。 │ ├── ActiveRecallRepository.findById(questionId) │ active-recall.repository.ts:19-21 │ → prisma.activeRecallQuestion.findUnique({ where: { id } }) │ → 不存在时 throw NotFoundException('问题不存在') │ → ⚠️ P0 安全缺陷:不校验 question.userId === userId │ 用户 A 可以提交用户 B 的 questionId,导致分析结果错配到 A 名下 │ ├── ActiveRecallRepository.createAnswer(userId, questionId, body) │ active-recall.repository.ts:41-50 │ → prisma.activeRecallAnswer.create({ userId, questionId, answerText, submittedAt }) │ → 表: ActiveRecallAnswer (id, userId, questionId, sessionId, answerType, answerText, audioFileId, submittedAt) │ → answerType 默认 "text" │ └── AiAnalysisService.analyze(userId, { questionText, knowledgeItemContent, userAnswer, answerId }) ai-analysis.service.ts:12-31 ├── AiAnalysisRepository.createJob(userId, 'active-recall', sessionId, answerId) │ ai-analysis.repository.ts:17-33 │ → prisma.aiJob.create({ │ userId, jobType: 'active-recall', sessionId, answerId, │ status: 'pending', │ lifecycleStatus: 'queued', // M-AI-02-10 Shadow Write │ queueName: 'ai-interactive', // ⚠️ 写入 'ai-interactive' 但实际入队 'ai-analysis' │ inputSchemaVersion: 'legacy-v1', │ attemptCount: 0, │ queuedAt: new Date() │ }) │ → 表: AiJob (物理表名 AiAnalysisJob) │ └── QueueService.add('ai-analysis', { jobId, userId, type: 'active-recall', questionText, knowledgeItemContent, userAnswer }) queue.service.ts:47-64 → BullMQ queue: 'ai-analysis' (QUEUE_AI_ANALYSIS) → queue.constants.ts:1 → 默认 JobOptions (queue-definitions.ts:61-66): { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, removeOnComplete: { count: 1000, age: 24*3600 }, // 保留最近 1000 条,24h removeOnFail: { count: 5000, age: 7*24*3600 } } // 保留最近 5000 条,7d → 写入 TaskLog 表(queueName + jobId + payload + status: 'enqueued') → 发布 task.enqueued 事件 ⚠️ analyze() 错误被 catch 并 log,不 re-throw → 答案返回不受 AI 入队结果影响 Worker: AiAnalysisWorker (@Processor('ai-analysis')) workers/ai-analysis.worker.ts:18-106 ├── AiAnalysisRepository.updateJobStatus(jobId, 'processing') │ ai-analysis.repository.ts:35-46 │ → 设置 status='processing', lifecycleStatus='running', startedAt=now │ ├── [type='active-recall'] → ActiveRecallAnalysisWorkflow.execute(input) │ modules/ai/workflows/active-recall-analysis.workflow.ts:17-41 │ └── AiGatewayService.generate({ │ feature: 'active-recall-analysis', │ userId, tier: 'primary', │ promptKey: 'active-recall-analysis', │ promptVersion: '1.0.0', │ messages: [{ role: 'user', content: userMessage }], │ outputSchema: ActiveRecallAnalysisResultSchema (Zod) │ }) │ modules/ai/gateway/ai-gateway.service.ts:40-170 │ ├── ModelRouter.resolve('primary') │ │ model-router.ts:70-72 │ │ → { tier: 'primary', preferred: { provider:'deepseek', model:'deepseek-v4-pro' }, │ │ fallback: { provider:'deepseek', model:'deepseek-v4-pro' }, maxRetries: 3 } │ │ ⚠️ preferred 和 fallback 相同 → 无实际 fallback 效果 │ │ │ ├── PromptTemplateService.get('active-recall-analysis', '1.0.0') │ │ prompt-template.service.ts:65-73 │ │ → 硬编码 TypeScript 常量(非 DB) │ │ → systemPrompt: ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT │ │ → outputSchemaDesc: ACTIVE_RECALL_OUTPUT_SCHEMA_DESC │ │ │ ├── DeepSeekProvider.generate({ model, messages, temperature: 0.3, maxTokens: 4096, responseFormat: 'json_object' }) │ │ → HTTP POST to DeepSeek API │ │ │ ├── ContentSafetyService.check(output.rawText, { contentType: 'ai_output' }) │ │ → 不安全时 throw Error('AI output blocked by content safety') │ │ │ ├── parseJson() - 3 层 JSON 解析: │ │ 1. 直接 JSON.parse → Zod schema.parse │ │ 2. 提取 markdown ```json``` fence │ │ 3. 提取第一个 {…} 对象 │ │ │ ├── AiUsageLogService.log({ userId, feature, provider, model, tier, promptKey, promptVersion, inputTokens, outputTokens, estimatedCost, latencyMs, success }) │ │ → 表: AiUsageLog (usage-log.service.ts) │ │ │ └── EventBusService.publish(AIUsageRecorded event) │ ├── AiAnalysisRepository.createResult(userId, jobId, result) │ ai-analysis.repository.ts:55-69 │ → prisma.aiAnalysisResult.create({ │ userId, jobId, │ summary: result.summary, │ masteryScore: result.score, │ strengths: result.strengths (Json), │ weaknesses: result.weaknesses (Json), │ suggestions: result.focusItems (Json), │ nextActions: result.reviewSuggestion (Json), │ rawResult: result (Json) │ }) │ → 表: AiAnalysisResult │ ├── AiAnalysisRepository.updateJobStatus(jobId, 'completed') │ → 设置 status='completed', lifecycleStatus='succeeded', finishedAt=now │ ├── EventBusService.publish(AIAnalysisCompleted event) │ → eventType: 'ai.analysis.completed' │ → payload: { userId, jobId, sessionId, answerId, type, score, analysis, timestamp } │ └── 消费方: ReviewCardSubscriber.handleAIAnalysisCompleted() │ modules/review/review-card.subscriber.ts:11-51 │ → ReviewService.generateCards(userId, { knowledgeItemTitle, knowledgeItemContent, cardCount }) │ modules/review/review.service.ts:68-99 │ → ReviewCardGenerationWorkflow.execute() → AiGatewayService.generate() │ → 创建 1-3 条 ReviewCard (SM-2: intervalDays=1, easeFactor=2.5, scheduleState='new') │ → 表: ReviewCard │ └── FocusItemsService.create(userId, { title: w, source: 'ai-analysis', status: 'open' }) → 为每个 result.weaknesses 元素创建一条 FocusItem → knowledgeBaseId: result.knowledgeBaseId || 'unknown' → ⚠️ 数据完整性问题:ActiveRecallAnalysisResultSchema 不含 knowledgeBaseId 字段 (active-recall-analysis.schema.ts:17-28),因此 result.knowledgeBaseId 恒为 undefined, 所有 FocusItem 的 knowledgeBaseId 恒为 'unknown',无法关联到具体知识库 → 表: FocusItem 错误处理: Worker catch → updateJobStatus(jobId, 'failed', err.message) → lifecycleStatus='failed' → throw err (触发 BullMQ 重试,默认 3 次指数退避) ``` ### 关键发现 | # | 发现 | 严重度 | 文件:行 | |---|------|--------|---------| | 1 | `queueName` 写入 `'ai-interactive'`,但实际 BullMQ 入队 `'ai-analysis'` | **P0** | `ai-analysis.repository.ts:28` vs `ai-analysis.service.ts:21` | | 2 | `ActiveRecallService.submit()` 不校验 `question.userId === userId`,用户 A 可提交用户 B 的问题 | **P0** | `active-recall.service.ts:20-21` | | 3 | 所有 FocusItem 的 `knowledgeBaseId` 恒为 `'unknown'`,因 AI 输出 Schema 不含此字段 | **P1** | `active-recall-analysis.schema.ts:17-28` → `ai-analysis.worker.ts:89` | | 4 | POST body 类型为 `any`,无 DTO 校验 | **P1** | `active-recall.controller.ts:21` | | 5 | `knowledgeItemContent` 硬编码为空字符串 `''`。此外该行注释 `// worker picks up content from the analysis workflow` 是**虚假注释**:Worker 不查询 DB 获取知识点内容,直接使用 Job data 中的 `knowledgeItemContent`(即空字符串)。AI 模型仅收到 `【知识点原文】\n\n` 而无实际内容,分析质量严重受损 | **HIGH** | `active-recall.service.ts:29` → `ai-analysis.service.ts:26` → `active-recall-analysis.workflow.ts:18-21` | | 6 | `removeOnComplete: { count: 1000, age: 24h }` / `removeOnFail: { count: 5000, age: 7d }` — completed Job 保留 24h(非立即删除),failed Job 保留 7d。影响故障排查窗口和存储容量估算,Unified 链路需匹配此行为 | **INFO** | `queue-definitions.ts:64-65` | | 7 | ModelRouter `primary` 和 `strong` tier 的 preferred/fallback 完全相同(均为 `deepseek-v4-pro`),`maxRetries: 3`。这意味着 4 次尝试全部打到同一个模型,fallback 机制形同虚设。生产环境中若 deepseek-v4-pro 故障或限流,重试只会重复失败,不会自动切换备用模型 | **HIGH** | `model-router.ts:24-29` | | 8 | Prompt 硬编码在 TypeScript 常量中,非 DB 管理 | INFO | `active-recall-analysis.prompt.ts` | | 9 | AI 分析入队失败不阻止答案返回(catch + log) | INFO | `active-recall.service.ts:34-36` | | 10 | `ActiveRecallAnswer` 包含 `audioFileId` 和 `answerType` 字段(`schema.prisma:554-556`),但当前 `submit()` 仅接受 `{ answerText }`。非文本答案(音频)在 Unified 链路中的处理方式未定义 | **P2** | `active-recall.controller.ts:21` → `schema.prisma:549-566` | | 11 | Worker stall 恢复(`maxStalledCount: 1`,`queue-definitions.ts:58`)可能导致重复 `AiAnalysisResult`:AI 调用成功后 Worker 崩溃 → BullMQ 重新投递 → 再次执行 → `createResult()` 无幂等保护(`AiAnalysisResult` 无 `@@unique([jobId])`)→ 同一 jobId 产生两条 Result | **HIGH** | `ai-analysis.worker.ts:67` → `ai-analysis.repository.ts:55` → `schema.prisma:679-700` → `queue-definitions.ts:58` | --- ## 2. 目标链路 ``` POST /api/active-recalls/:id/submit → ActiveRecallService → ActiveRecallExecutionRouter (NEW) ├─ legacy → 原有 AiAnalysisService 路径(不改动) └─ unified → AiJobCreationService → AiJob + AiJobSnapshot + OutboxEvent → Outbox Dispatcher → Queue: ai-interactive → AiJobExecutionEngine → ActiveRecallExecutor → Validation (Business + Reference) → ActiveRecallProjector → 业务结果 + AiJobArtifact ``` --- ## 3. 输入 Snapshot Schema(已冻结) ```json { "schemaVersion": "active-recall-v1", "snapshot": { "userId": "", "activeRecallId": "", "knowledgeItemId": "", "questionText": "", "userAnswer": "", "referenceAnswer": "", "answerId": "", "submittedAt": "", "promptKey": "active-recall-analysis", "promptVersion": "1.0.0", "modelTier": "primary", "modelProvider": "deepseek", "modelName": "deepseek-v4-pro", "maxTokens": 4096, "temperature": 0.3 } } ``` ### 禁止字段 - JWT / Authorization Header - 模型 API Key - Cookie - 数据库连接信息 - 无关用户画像 - 未脱敏 Credential - 用户邮箱、手机号等 PII ### 区分原则 | 数据 | 归属 | 理由 | |------|------|------| | userId, activeRecallId, questionText, userAnswer, answerId | Snapshot(冻结) | 重放 AI 调用所需 | | knowledgeItemContent | 执行时重新查询 | 知识点内容可能更新 | | knowledgeItemId | Snapshot(冻结) | 关联追溯 | | referenceAnswer | 执行时重新查询 | 从 KnowledgeItem 获取 | | promptKey, promptVersion, modelTier | Snapshot(冻结) | 重放一致性 | | 用户 email/phone/displayName | 禁止 | PII | --- ## 4. 输出 Schema(已冻结) ```json { "score": "", "masteryLevel": "'excellent' | 'good' | 'partial' | 'weak' | 'none', 必填", "summary": "", "strengths": [""], "weaknesses": [""], "missingKeyPoints": [""], "misconceptions": [""], "weaknessTypes": [""], "focusItems": [ { "title": "", "reason": "", "suggestion": "", "priority": "'high' | 'normal' | 'low'" } ], "reviewSuggestion": { "shouldReview": "", "intervalDays": "", "cardFront": "", "cardBack": "" } } ``` ### 验证规则 | 字段 | 规则 | 来源 | |------|------|------| | score | 0 ≤ score ≤ 100, 整数 | `active-recall-analysis.schema.ts:18` | | masteryLevel | enum: excellent/good/partial/weak/none | `active-recall-analysis.schema.ts:19` | | summary | 1-2000 字符 | `active-recall-analysis.schema.ts:20` | | strengths | 最多 10 项, 每项 ≤500 字符 | `active-recall-analysis.schema.ts:21` | | weaknesses | 最多 10 项, 每项 ≤500 字符 | `active-recall-analysis.schema.ts:22` | | focusItems | 最多 10 项 | `active-recall-analysis.schema.ts:26` | | reviewSuggestion | 必填, intervalDays 1-365 | `active-recall-analysis.schema.ts:27` | --- ## 5. 副作用矩阵(已审计) | 操作 | 表/实体 | 触发条件 | 写入方 | 文件:行 | |------|---------|----------|--------|---------| | 创建答案记录 | `ActiveRecallAnswer` | 每次提交 | `ActiveRecallRepository.createAnswer()` | `active-recall.repository.ts:41` | | 创建 Job | `AiJob` (AiAnalysisJob) | 每次提交 | `AiAnalysisRepository.createJob()` | `ai-analysis.repository.ts:17` | | 创建 TaskLog | `TaskLog` | 每次入队 | `QueueService.add()` | `queue.service.ts:59` | | 更新 Job → processing | `AiJob` | Worker 开始执行 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:48` | | 调用 AI 模型 | DeepSeek API | Worker 执行 | `AiGatewayService.generate()` | `ai-gateway.service.ts:58` | | 记录 UsageLog | `AiUsageLog` | AI 调用完成 | `AiUsageLogService.log()` | `ai-gateway.service.ts:78` | | 发布 AIUsageRecorded | EventBus | AI 调用完成 | `AiGatewayService` | `ai-gateway.service.ts:94` | | 安全审核 | ContentSafety | AI 输出后 | `ContentSafetyService.check()` | `ai-gateway.service.ts:67` | | 创建 FallbackEvent | `FallbackEvent` | 首次调用失败切备用 | `AiGatewayService` | `ai-gateway.service.ts:127` | | 创建分析结果 | `AiAnalysisResult` | Worker 成功后 | `AiAnalysisRepository.createResult()` | `ai-analysis.worker.ts:67` | | 更新 Job → completed | `AiJob` | Worker 成功后 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:68` | | 发布 AIAnalysisCompleted | EventBus | Worker 成功后 | `AiAnalysisWorker` | `ai-analysis.worker.ts:72` | | 生成复习卡片 | `ReviewCard` | 收到 AIAnalysisCompleted 事件 | `ReviewCardSubscriber` → `ReviewService.generateCards()` | `review-card.subscriber.ts:39` | | 创建薄弱项 | `FocusItem` | result.weaknesses.length > 0 | `FocusItemsService.create()` | `ai-analysis.worker.ts:88` | ⚠️ knowledgeBaseId 恒为 'unknown' | | 更新 Job → failed | `AiJob` | Worker 失败 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:102` | --- ## 6. 状态映射(已冻结) | 业务阶段 | 旧 Job 状态 (`status`) | 新 `lifecycleStatus` | Active Recall 业务状态 | 客户端可见 | |----------|----------------------|---------------------|----------------------|-----------| | 提交答案 | `pending` | `queued` | 答案已提交,等待 AI 分析 | 答案已提交 | | AI 分析执行中 | `processing` | `running` | AI 正在分析回答 | 分析中 | | 分析完成 | `completed` | `succeeded` | 分析完成,结果可用 | 分析完成(可查看结果) | | 分析失败 | `failed` | `failed` | 分析失败(自动重试后仍失败) | 分析失败 | | 取消 | N/A(旧链路不支持) | `cancelled` | 分析已取消 | 分析取消 | ### Shadow Write 映射(ai-analysis.repository.ts:10-15) ``` pending → queued processing → running completed → succeeded failed → failed ``` --- ## 7. 幂等键 ``` active-recall: ``` - **稳定业务标识**:`answerId` — `ActiveRecallAnswer.id`,每次提交生成唯一 ID - **唯一约束**:`AiJob.@@unique([userId, jobType, idempotencyKey])` (`schema.prisma:636`) - **冲突处理**:P2002 时返回已有 Job(由 `AiJobCreationService` 实现) - **格式**:`active-recall:` --- ## 8. Feature Flag | 属性 | 值 | |------|-----| | 配置名 | `ACTIVE_RECALL_ENGINE_MODE` | | 值 | `legacy` \| `unified` | | 默认 | `legacy`(切换前) | | 白名单 | 支持用户 ID 白名单(通过 `FeatureFlagService`) | | 存储 | `FeatureFlag` 表 + Redis 缓存(30s TTL) | | 分支点 | `ActiveRecallExecutionRouter`(待实现,M-AI-04-05) | | 切换方式 | 修改 FeatureFlag 值,无需重启 | ### 配置机制(现有基础设施) - **FeatureFlagService**: `FeatureFlag` 表 (`name`, `enabled`, `whitelist`, `rolloutPct`),Redis 缓存 30s - `config/feature-flag.service.ts` - **AppConfigService**: `AppConfig` 表 (key-value),Redis 缓存 60s - `config/config.service.ts` --- ## 9. 回滚流程 ``` unified → legacy: 1. 修改 ACTIVE_RECALL_ENGINE_MODE=legacy(通过 Admin 或 FeatureFlag API) 2. 新请求立即走 Legacy 路径(ActiveRecallExecutionRouter 读取 Flag) 3. 已创建的新 Job 继续完成或取消(不强制中断) 4. 不重新送入 Legacy(避免重复分析) 5. 客户端仍可查询已有新 Job(GET /api/ai/jobs/:jobId) 6. 已写入的 AiAnalysisResult / FocusItem / ReviewCard 不删除 ``` ### 禁止事项 - 禁止 Legacy 和 Unified 双链路同时执行同一 answerId(通过幂等键保证) - 禁止在无运行证据时直接全量切换 - 禁止自动 Legacy fallback(必须通过 Flag 显式切换) --- ## 10. 不确定项 - [x] 确认 `knowledgeItemContent` 来源:当前硬编码 `''`,Worker 不查 DB。迁移后 Snapshot 仍可为空,Executor 可从 knowledgeItemId 查询。 - [x] 确认 `queueName` 不一致:当前 DB 写入 `ai-interactive` 但 BullMQ 路由 `ai-analysis`。迁移后 Unified 路径统一使用 `ai-interactive`。 - [x] 确认 ReviewCard 生成是否需要保留:是,`AIAnalysisCompleted` → `ReviewCardSubscriber` 链路由 EventBus 驱动,与 Job 系统解耦。 - [x] 确认认证/权限缺陷:JwtAuthGuard 为全局 Guard(`app.module.ts:184`),认证层面安全;但 `submit()` 不校验跨用户所有权(P0),需在 M-AI-04-05 修复。 - [x] 确认 FocusItem knowledgeBaseId 恒为 `'unknown'`(P1):`ActiveRecallAnalysisResultSchema` 不含 `knowledgeBaseId` 字段,需在 M-AI-04-03 输出 Schema 中增加该字段。 - [x] 确认 Job 保留策略:`removeOnComplete: { count: 1000, age: 24h }` / `removeOnFail: { count: 5000, age: 7d }`(`queue-definitions.ts:64-65`),Unified 链路 `ai-interactive` 队列使用相同默认值。 - [ ] 待 M-AI-04-02:Snapshot 是否包含 `knowledgeItemContent`(查询时获取 vs 快照冻结)— 建议:不包含,Executor 执行时实时查询。 - [ ] 待 M-AI-04-03:Executor 是否复用现有 `ActiveRecallAnalysisWorkflow` 还是新建。 - [ ] 待 M-AI-04-05:`ActiveRecallExecutionRouter` 的分支粒度(per-request vs per-user vs per-session)。 - [ ] 待 M-AI-04-03/04:非文本答案(`audioFileId`)在 Unified 链路的处理方式。当前 `submit()` 仅处理 `{ answerText }`,但 `ActiveRecallAnswer` 模型包含 `audioFileId` 和 `answerType` 字段。若支持音频答案,需语音转文本步骤或独立的音频分析 Executor。 - [ ] 待 M-AI-04-04:Worker stall 恢复的重复 `AiAnalysisResult` 风险。`maxStalledCount: 1` + `AiAnalysisResult` 无 `@@unique([jobId])` 约束 → 崩溃重试可能产生重复结果。Unified 链路的 `ActiveRecallProjector` 必须在 Projector 层提供幂等保证。 --- ## 关联 Issue | Issue | 标题 | Gitea | |-------|------|-------| | M-AI-04-01 | 审计并冻结迁移契约 | [#296](https://git.admin.longde.cloud/wangdl/api-server/issues/296) ✅ 本 Issue | | M-AI-04-02 | 注册 Definition 与 Snapshot | [#297](https://git.admin.longde.cloud/wangdl/api-server/issues/297) | | M-AI-04-03 | Executor 与输出验证 | [#298](https://git.admin.longde.cloud/wangdl/api-server/issues/298) | | M-AI-04-04 | Projector、Artifact 与幂等写入 | [#299](https://git.admin.longde.cloud/wangdl/api-server/issues/299) | | M-AI-04-05 | 入口接入 CreationService | [#300](https://git.admin.longde.cloud/wangdl/api-server/issues/300) | | M-AI-04-06 | 状态兼容、可观测性与回滚 | [#301](https://git.admin.longde.cloud/wangdl/api-server/issues/301) | | M-AI-04-07 | 真实业务 E2E 与受控切换 | [#302](https://git.admin.longde.cloud/wangdl/api-server/issues/302) | | M-AI-04-08-GATE | 最终验收与切换 | [#303](https://git.admin.longde.cloud/wangdl/api-server/issues/303) |