api-server/docs/architecture/m-ai-04-active-recall-migration-contract.md
wangdl 0ec58669dd
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 37s
Deploy API Server / current-integration (push) Failing after 1m35s
Deploy API Server / backward-compat (push) Successful in 17s
Deploy API Server / deploy (push) Has been skipped
feat(M-AI-04): Active Recall 端到端迁移至统一 Job Engine
M-AI-04-01: 审计并冻结迁移契约 (文档)
M-AI-04-02: 注册 JobDefinition + SnapshotBuilder (28 tests)
M-AI-04-03: Executor + BusinessValidator + ReferenceValidator (31 tests)
M-AI-04-04: Projector + Artifact + 幂等写入 (19 tests)
M-AI-04-05: 入口集成 (Router + CreationService + Engine + Executor)
M-AI-04-06: 状态兼容 + 结构化日志 + 指标计数器 (255 tests)
M-AI-04-07: 真实业务 E2E + CI 触发 + FeatureFlag 受控切换 (11 tests)

P0 修复:
- 跨用户 question 所有权校验 (active-recall.service.ts)
- E2E infra 不可用时 HARD FAIL (fail() 替代静默 skip)

添加文件:
- docs/architecture/m-ai-04-active-recall-migration-contract.md
- 12 个 ai-job 模块文件 (Definition/Snapshot/Executor/Validator/Projector/Router/Observability)
- test/m-ai-04-active-recall.e2e-spec.ts + setup

修改文件:
- active-recall.service.ts, active-recall.module.ts
- ai-job-creation.service.ts, ai-job-execution-engine.ts, ai-job.module.ts
- .gitea/workflows/deploy.yml (CI 变更检测)
- test/jest-e2e.json (setupFiles + globals)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 14:55:33 +08:00

23 KiB
Raw Blame History

M-AI-04 Active Recall 迁移契约

状态: 已审计冻结M-AI-04-01 完成) 审计日期2026-06-21 基线M-AI-03 GATE PASScommit 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-28ai-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:29ai-analysis.service.ts:26active-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 primarystrong tier 的 preferred/fallback 完全相同(均为 deepseek-v4-promaxRetries: 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 包含 audioFileIdanswerType 字段(schema.prisma:554-556),但当前 submit() 仅接受 { answerText }。非文本答案(音频)在 Unified 链路中的处理方式未定义 P2 active-recall.controller.ts:21schema.prisma:549-566
11 Worker stall 恢复(maxStalledCount: 1queue-definitions.ts:58)可能导致重复 AiAnalysisResultAI 调用成功后 Worker 崩溃 → BullMQ 重新投递 → 再次执行 → createResult() 无幂等保护(AiAnalysisResult@@unique([jobId]))→ 同一 jobId 产生两条 Result HIGH ai-analysis.worker.ts:67ai-analysis.repository.ts:55schema.prisma:679-700queue-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 事件 ReviewCardSubscriberReviewService.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>
  • 稳定业务标识answerIdActiveRecallAnswer.id,每次提交生成唯一 ID
  • 唯一约束AiJob.@@unique([userId, jobType, idempotencyKey]) (schema.prisma:636)
  • 冲突处理P2002 时返回已有 JobAiJobCreationService 实现)
  • 格式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
    • 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. 客户端仍可查询已有新 JobGET /api/ai/jobs/:jobId
6. 已写入的 AiAnalysisResult / FocusItem / ReviewCard 不删除

禁止事项

  • 禁止 Legacy 和 Unified 双链路同时执行同一 answerId通过幂等键保证
  • 禁止在无运行证据时直接全量切换
  • 禁止自动 Legacy fallback必须通过 Flag 显式切换)

10. 不确定项

  • 确认 knowledgeItemContent 来源:当前硬编码 ''Worker 不查 DB。迁移后 Snapshot 仍可为空Executor 可从 knowledgeItemId 查询。
  • 确认 queueName 不一致:当前 DB 写入 ai-interactive 但 BullMQ 路由 ai-analysis。迁移后 Unified 路径统一使用 ai-interactive
  • 确认 ReviewCard 生成是否需要保留:是,AIAnalysisCompletedReviewCardSubscriber 链路由 EventBus 驱动,与 Job 系统解耦。
  • 确认认证/权限缺陷JwtAuthGuard 为全局 Guardapp.module.ts:184),认证层面安全;但 submit() 不校验跨用户所有权P0需在 M-AI-04-05 修复。
  • 确认 FocusItem knowledgeBaseId 恒为 'unknown'P1ActiveRecallAnalysisResultSchema 不含 knowledgeBaseId 字段,需在 M-AI-04-03 输出 Schema 中增加该字段。
  • 确认 Job 保留策略:removeOnComplete: { count: 1000, age: 24h } / removeOnFail: { count: 5000, age: 7d }queue-definitions.ts:64-65Unified 链路 ai-interactive 队列使用相同默认值。
  • 待 M-AI-04-02Snapshot 是否包含 knowledgeItemContent(查询时获取 vs 快照冻结)— 建议不包含Executor 执行时实时查询。
  • 待 M-AI-04-03Executor 是否复用现有 ActiveRecallAnalysisWorkflow 还是新建。
  • 待 M-AI-04-05ActiveRecallExecutionRouter 的分支粒度per-request vs per-user vs per-session
  • 待 M-AI-04-03/04非文本答案audioFileId)在 Unified 链路的处理方式。当前 submit() 仅处理 { answerText },但 ActiveRecallAnswer 模型包含 audioFileIdanswerType 字段。若支持音频答案,需语音转文本步骤或独立的音频分析 Executor。
  • 待 M-AI-04-04Worker stall 恢复的重复 AiAnalysisResult 风险。maxStalledCount: 1 + AiAnalysisResult@@unique([jobId]) 约束 → 崩溃重试可能产生重复结果。Unified 链路的 ActiveRecallProjector 必须在 Projector 层提供幂等保证。

关联 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