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>
407 lines
23 KiB
Markdown
407 lines
23 KiB
Markdown
# 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": "<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(已冻结)
|
||
|
||
```json
|
||
{
|
||
"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` | ⚠️ 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>
|
||
```
|
||
|
||
- **稳定业务标识**:`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
|
||
- `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) |
|