M-AI-04 Active Recall 迁移契约
状态:✅ 已审计冻结(M-AI-04-01 完成)
审计日期:2026-06-21
基线:M-AI-03 GATE PASS(commit 5108a9a)
对应 Issue:#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(已冻结)
{
"schemaVersion": "active-recall-v1",
"snapshot": {
"userId": "<string, 必填>",
"activeRecallId": "<string, 问题 ID,必填>",
"knowledgeItemId": "<string | null, 问题关联的知识点 ID>",
"questionText": "<string, 问题原文,必填>",
"userAnswer": "<string, 用户回答文本,必填>",
"referenceAnswer": "<string | null, 参考答案/标准内容>",
"answerId": "<string, ActiveRecallAnswer.id,必填>",
"submittedAt": "<ISO8601 timestamp, 答案提交时间>",
"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(已冻结)
{
"score": "<number, 0-100, 必填>",
"masteryLevel": "'excellent' | 'good' | 'partial' | 'weak' | 'none', 必填",
"summary": "<string, 1-2000 字符, 必填>",
"strengths": ["<string[], 最多 10 项>"],
"weaknesses": ["<string[], 最多 10 项>"],
"missingKeyPoints": ["<string[], 最多 20 项>"],
"misconceptions": ["<string[], 最多 10 项>"],
"weaknessTypes": ["<string[], missing_detail|missing_application|misconception|vague_expression|incomplete_structure|wrong_emphasis>"],
"focusItems": [
{
"title": "<string, 1-255>",
"reason": "<string, 1-1000>",
"suggestion": "<string, optional>",
"priority": "'high' | 'normal' | 'low'"
}
],
"reviewSuggestion": {
"shouldReview": "<boolean>",
"intervalDays": "<number, 1-365>",
"cardFront": "<string, optional>",
"cardBack": "<string, optional>"
}
}
验证规则
| 字段 |
规则 |
来源 |
| 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 |
| 更新 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>
- 稳定业务标识:
answerId — ActiveRecallAnswer.id,每次提交生成唯一 ID
- 唯一约束:
AiJob.@@unique([userId, jobType, idempotencyKey]) (schema.prisma:636)
- 冲突处理:P2002 时返回已有 Job(由
AiJobCreationService 实现)
- 格式:
active-recall:<answerId>
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
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. 不确定项
关联 Issue
| Issue |
标题 |
Gitea |
| M-AI-04-01 |
审计并冻结迁移契约 |
#296 ✅ 本 Issue |
| M-AI-04-02 |
注册 Definition 与 Snapshot |
#297 |
| M-AI-04-03 |
Executor 与输出验证 |
#298 |
| M-AI-04-04 |
Projector、Artifact 与幂等写入 |
#299 |
| M-AI-04-05 |
入口接入 CreationService |
#300 |
| M-AI-04-06 |
状态兼容、可观测性与回滚 |
#301 |
| M-AI-04-07 |
真实业务 E2E 与受控切换 |
#302 |
| M-AI-04-08-GATE |
最终验收与切换 |
#303 |