feat(M-AI-02): schema audit (02-01) + ADR freeze (02-02)

M-AI-02-01: Production DB audit of all 5 AI tables (9 rows, 0 orphans, 37 test refs)
M-AI-02-02: Frozen AiJob Expand ADR — 43-column schema, 6 indexes, state mapping

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-20 12:24:16 +08:00
parent 789d6ec15c
commit 800ce4a2b2
3 changed files with 1073 additions and 0 deletions

3
.gitignore vendored
View File

@ -41,6 +41,9 @@ prisma/seed.d.ts
*-error-*.png
*-run-*.png
# Audit scripts (ephemeral, may contain credentials in env vars)
scripts/
# Misc
.cache/
tmp/

View File

@ -0,0 +1,418 @@
# ADR-002AiJob 数据库 Expand — Schema 冻结
## 状态
已接受2026-06-20
## 背景
M-AI-02-01 审计确认:
- **AiAnalysisJob**(系统 A13 字段9 行生产数据):承载 active-recall 和 feynman-evaluation 分析
- **AiRuntimeJob**(系统 B31 字段0 行生产数据):设计支持 5 种分析类型但无生产者
- 两表无 FK 关联jobType 不重叠,状态语义不兼容
- 生产数据量极小(< 0.12 MB / Online DDL 毫秒级完成
- **Minimax API Key 已到期**2026-06-06生产已全量切换 DeepSeek V4 Pro/Flash
### 目标
在现有 `AiAnalysisJob` 物理表上 Expand-only**不 drop 列、不 rename 表名、不重建表**。允许 2 处列重命名(`completedAt``finishedAt``errorMessage``internalErrorMessage`),理由见 §7 DDL 注释。将两套系统字段统一为单一 `AiJob` Prisma model后续 Issue 只按本 ADR 实现。
---
## 决策
### 1. 物理表策略
| 项目 | 决策 | 依据 |
|------|------|------|
| 物理表名 | **保持 `AiAnalysisJob`** | M-AI-02-01 §1.1:现有生产数据在此表 |
| Prisma model 名 | **`AiJob`**`@@map("AiAnalysisJob")` | 统一命名;`@@map` 避免物理表 rename |
| AiRuntimeJob 表 | **不删除、不修改、不迁移** | 0 行生产数据M-AI-08 退场时一并清理 |
| AiRuntimeResult 表 | **不修改** | M-AI-03 统一 Job Engine 后用新的 Result 模型 |
| AiAnalysisResult 表 | **不修改** | 保留旧系统 A 的结果存储M-AI-04 迁移后评估删除 |
### 2. 统一 AiJob Schema
#### 2.1 Prisma 草图
```prisma
model AiJob {
id String @id @default(cuid())
userId String
// ── M-AI-02-03: 身份与路由 ──
jobType String @db.VarChar(64) // 来源: AiAnalysisJob.jobType (expand 32→64)
triggerType String @default("api") @db.VarChar(32)
queueName String @default("ai-interactive") @db.VarChar(64)
targetType String? @db.VarChar(32) // 来源: AiRuntimeJob.targetType
targetId String? @db.VarChar(255) // 来源: AiRuntimeJob.targetId
parentJobId String? @db.VarChar(255)
idempotencyKey String? @unique @db.VarChar(255) // 来源: AiRuntimeJob.idempotencyKey
retriedFromJobId String? @db.VarChar(255) // 来源: AiRuntimeJob.retriedFromJobId
// ── M-AI-02-04: 生命周期与执行 ──
lifecycleStatus String @default("pending") @db.VarChar(32) // 来源: AiAnalysisJob.status + AiRuntimeJob.status
priority Int @default(0) // 来源: AiRuntimeJob.priority
progress Int @default(0) // 来源: AiAnalysisJob.progress
lockedBy String? @db.VarChar(100) // 来源: AiRuntimeJob.lockedBy
lockedAt DateTime?
lockUntil DateTime?
startedAt DateTime? // 来源: AiAnalysisJob.startedAt + AiRuntimeJob.startedAt
finishedAt DateTime? // 来源: AiAnalysisJob.completedAt + AiRuntimeJob.finishedAt
queuedAt DateTime? // 来源: AiAnalysisJob.queuedAt
cancelRequestedAt DateTime? // 来源: AiRuntimeJob.cancelRequestedAt
attemptCount Int @default(0) // 来源: AiRuntimeJob.retryCount (重命名)
maxAttempts Int @default(3) // 来源: AiRuntimeJob.maxRetryCount (重命名)
timeoutMs Int @default(120000) // 来源: AiRuntimeJob.timeoutSeconds × 1000
// ── M-AI-02-04: 输入输出 ──
inputRef String? @db.VarChar(255) // 新增
inputSchemaVersion String? @db.VarChar(100)
snapshotId String? @db.VarChar(255) // 来源: AiRuntimeJob.snapshotId
promptKey String? @db.VarChar(128) // 新增
promptVersion String? @db.VarChar(100) // 来源: AiRuntimeJob.promptVersion
outputSchemaVersion String? @db.VarChar(100) // 来源: AiRuntimeJob.outputSchemaVersion
// ── M-AI-02-04: 凭据与模型 ──
credentialMode String @default("platform_key") @db.VarChar(32) // 来源: AiRuntimeJob.apiKeyMode
credentialId String? @db.VarChar(255) // 来源: AiRuntimeJob.credentialId
modelTier String @default("primary") @db.VarChar(32)
modelProvider String @default("deepseek") @db.VarChar(32) // 来源: AiRuntimeJob.modelProvider
modelName String @default("deepseek-chat") @db.VarChar(64) // 来源: AiRuntimeJob.modelName
// ── M-AI-02-04: 错误与输出 ──
errorCode String? @db.VarChar(100) // 来源: AiRuntimeJob.errorCode
publicErrorMessage String? @db.VarChar(500) // 新增:面向用户的错误
internalErrorMessage String? @db.Text // 来源: AiAnalysisJob.errorMessage + AiRuntimeJob.errorMessage
outputHash String? @db.VarChar(255) // 来源: AiRuntimeResult.outputHash
validatedOutput Json? // 来源: AiRuntimeResult.validatedOutput脱敏后可缓存
// ── 向后兼容字段(标记待删除)──
sessionId String? @db.VarChar(191) // 旧系统 A100% NULL in prod
answerId String? @db.VarChar(191) // 旧系统 A78% NULL in prod
status String? @db.VarChar(32) // 🔴 @deprecated:旧系统 A 状态列。过渡期 Shadow Write 使用M-AI-05 移除
// ── 时间戳 ──
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// ── 关联 ──
user User @relation(fields: [userId], references: [id], onDelete: Restrict, onUpdate: Cascade)
parentJob AiJob? @relation("JobChildren", fields: [parentJobId], references: [id], onDelete: SetNull, onUpdate: Cascade)
children AiJob[] @relation("JobChildren")
snapshots AiJobSnapshot[]
artifacts AiJobArtifact[]
usageLogs AiUsageLog[]
@@map("AiAnalysisJob")
@@index([userId, jobType, createdAt])
@@index([lifecycleStatus, createdAt])
@@index([queueName, lifecycleStatus, createdAt])
@@index([targetType, targetId])
@@index([parentJobId])
@@index([userId, jobType, idempotencyKey])
}
```
> **过渡期 `status` 列说明**`status` 是物理表 `AiAnalysisJob` 中已存在 13 个月之久的列(`VARCHAR(32) NOT NULL DEFAULT 'pending'`),旧代码 `AiAnalysisRepository.updateJobStatus()` 直接读写此列。M-AI-02-09 将 Prisma model 重命名为 `AiJob` 后,**必须在过渡期模型中保留 `status` 字段**(标记 `@deprecated`),否则 Shadow Write 机制§4缺少 Prisma 层面的实现路径。`status` 列在 M-AI-05Active Recall + Feynman 全部迁移完成后)通过 migration 删除。
#### 2.2 字段逐一定义
##### 身份与路由M-AI-02-03
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 | 未来删除阶段 |
|------------|---------|------|----------|---------|-------|---------|-----------|------------|
| `jobType` | `jobType` | VARCHAR(64) | ❌ | - | 复合 | API → Controller → Service | `AiAnalysisJob.jobType`expand 32→64 | - |
| `triggerType` | `triggerType` | VARCHAR(32) | ❌ | `'api'` | ❌ | Service 层根据调用方设定 | 新增 | - |
| `queueName` | `queueName` | VARCHAR(64) | ❌ | `'ai-interactive'` | 复合 | Service 层根据 jobType 路由 | 新增 | - |
| `targetType` | `targetType` | VARCHAR(32) | ✅ | - | 复合 | API DTO | `AiRuntimeJob.targetType` | - |
| `targetId` | `targetId` | VARCHAR(255) | ✅ | - | 复合 | API DTO | `AiRuntimeJob.targetId` | - |
| `parentJobId` | `parentJobId` | VARCHAR(255) | ✅ | - | ✅ 单列 | Service → 子 Job 创建时赋值 | 新增 | - |
| `idempotencyKey` | `idempotencyKey` | VARCHAR(255) | ✅ | - | ✅ UNIQUE | API DTOiOS 提供 UUID | `AiRuntimeJob.idempotencyKey` | - |
| `retriedFromJobId` | `retriedFromJobId` | VARCHAR(255) | ✅ | - | ❌ | Worker → 重试时赋值 | `AiRuntimeJob.retriedFromJobId` | M-AI-08 |
##### 生命周期与执行M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 | 未来删除阶段 |
|------------|---------|------|----------|---------|-------|---------|-----------|------------|
| `lifecycleStatus` | `lifecycleStatus` | VARCHAR(32) | ❌ | `'pending'` | ✅ 复合 | Worker/Reaper/User cancel | `AiAnalysisJob.status` + `AiRuntimeJob.status`(统一状态枚举) | - |
| `priority` | `priority` | INT | ❌ | `0` | ❌ | PriorityRulesService | `AiRuntimeJob.priority` | - |
| `progress` | `progress` | INT | ❌ | `0` | ❌ | Worker heartbeat | `AiAnalysisJob.progress` | - |
| `lockedBy` | `lockedBy` | VARCHAR(100) | ✅ | - | ❌ | Runtime lock | `AiRuntimeJob.lockedBy` | - |
| `lockedAt` | `lockedAt` | DATETIME(3) | ✅ | - | ❌ | Runtime lock | `AiRuntimeJob.lockedAt` | - |
| `lockUntil` | `lockUntil` | DATETIME(3) | ✅ | - | ❌ | Runtime heartbeat | `AiRuntimeJob.lockUntil` | - |
| `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | Worker/Runtime → 首次 heartbeat | `AiAnalysisJob.startedAt` + `AiRuntimeJob.startedAt` | - |
| `finishedAt` | `finishedAt` | DATETIME(3) | ✅ | - | ❌ | Worker/Runtime → 终态 | `AiAnalysisJob.completedAt` + `AiRuntimeJob.finishedAt` | - |
| `queuedAt` | `queuedAt` | DATETIME(3) | ✅ | - | ❌ | Service → create | `AiAnalysisJob.queuedAt` | - |
| `cancelRequestedAt` | `cancelRequestedAt` | DATETIME(3) | ✅ | - | ❌ | User/Admin cancel | `AiRuntimeJob.cancelRequestedAt` | - |
| `attemptCount` | `attemptCount` | INT | ❌ | `0` | ❌ | Worker → 重试时递增 | `AiRuntimeJob.retryCount`(重命名) | - |
| `maxAttempts` | `maxAttempts` | INT | ❌ | `3` | ❌ | JobType config | `AiRuntimeJob.maxRetryCount`(重命名) | - |
| `timeoutMs` | `timeoutMs` | INT | ❌ | `120000` | ❌ | JobType config | `AiRuntimeJob.timeoutSeconds × 1000`(单位变更) | - |
##### 输入输出M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 | 未来删除阶段 |
|------------|---------|------|----------|---------|-------|---------|-----------|------------|
| `inputRef` | `inputRef` | VARCHAR(255) | ✅ | - | ❌ | Service → create | 新增 | - |
| `inputSchemaVersion` | `inputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | Service → create | 新增 | - |
| `snapshotId` | `snapshotId` | VARCHAR(255) | ✅ | - | ❌ | SnapshotBuilderService | `AiRuntimeJob.snapshotId` | - |
| `promptKey` | `promptKey` | VARCHAR(128) | ✅ | - | ❌ | Service → create | 新增(`AiUsageLog.promptKey` 对应) | - |
| `promptVersion` | `promptVersion` | VARCHAR(100) | ✅ | - | ❌ | Service → create | `AiRuntimeJob.promptVersion` | - |
| `outputSchemaVersion` | `outputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | Service → create | `AiRuntimeJob.outputSchemaVersion` | - |
##### 凭据与模型M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 |
|------------|---------|------|----------|---------|-------|---------|-----------|
| `credentialMode` | `credentialMode` | VARCHAR(32) | ❌ | `'platform_key'` | ❌ | Settings → Service | `AiRuntimeJob.apiKeyMode`(重命名) |
| `credentialId` | `credentialId` | VARCHAR(255) | ✅ | - | ❌ | Settings → Service | `AiRuntimeJob.credentialId` |
| `modelTier` | `modelTier` | VARCHAR(32) | ❌ | `'primary'` | ❌ | ModelRoute config | 新增 |
| `modelProvider` | `modelProvider` | VARCHAR(32) | ❌ | `'deepseek'` | ❌ | ModelRoute config | `AiRuntimeJob.modelProvider` |
| `modelName` | `modelName` | VARCHAR(64) | ❌ | `'deepseek-chat'` | ❌ | ModelRoute config | `AiRuntimeJob.modelName` |
##### 错误与输出M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 |
|------------|---------|------|----------|---------|-------|---------|-----------|
| `errorCode` | `errorCode` | VARCHAR(100) | ✅ | - | ❌ | Worker → fail | `AiRuntimeJob.errorCode` |
| `publicErrorMessage` | `publicErrorMessage` | VARCHAR(500) | ✅ | - | ❌ | Worker → fail脱敏后 | 新增 |
| `internalErrorMessage` | `internalErrorMessage` | TEXT | ✅ | - | ❌ | Worker → fail完整堆栈 | `AiAnalysisJob.errorMessage` + `AiRuntimeJob.errorMessage` |
| `outputHash` | `outputHash` | VARCHAR(255) | ✅ | - | ❌ | Worker → success | `AiRuntimeResult.outputHash` |
| `validatedOutput` | `validatedOutput` | JSON | ✅ | - | ❌ | Worker → success脱敏后缓存 | `AiRuntimeResult.validatedOutput` |
##### 向后兼容字段
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | 当前 NULL 率 | 计划 |
|------------|---------|------|----------|---------|-------------|------|
| `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | 0% | 🔴 **过渡期保留**Shadow Write 必需)。旧代码 `AiAnalysisRepository.updateJobStatus()` 直接读写。M-AI-05 移除 |
| `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | 100% (9/9) | 保留但废弃(旧系统 AM-AI-05 删除 |
| `answerId` | `answerId` | VARCHAR(191) | ✅ | - | 78% (7/9) | 保留但废弃(旧系统 AM-AI-05 删除 |
### 3. 状态映射表
两套旧状态统一为单一 `lifecycleStatus`
| 旧 AiAnalysisJob.status | 旧 AiRuntimeJob.status | 新 lifecycleStatus | 含义 | 进入条件 | 退出条件 |
|------------------------|----------------------|-------------------|------|---------|---------|
| `pending` | `pending` | **`pending`** | 等待消费 | API 创建 / retryable fail 回退 | 被 Runtime lock / BullMQ claim |
| - | `locked` | **`locked`** | 已被 Runtime 获取,等待执行确认 | Runtime POST /lock 成功 | heartbeat → running / lockUntil 超时 |
| `processing` | `running` | **`running`** | 正在执行 | heartbeat 确认(新)/ Worker process() 开始(旧) | 完成 → succeeded / 失败 → failed |
| `completed` | `succeeded` | **`succeeded`** | 执行成功 | API POST /result | 终态 |
| `failed` | `failed` | **`failed`** | 执行失败 | 不可重试错误 / 超过 maxAttempts | 终态Admin 可重跑) |
| - | `cancelled` | **`cancelled`** | 已取消 | 用户/Admin 取消 pending job / cancelRequested 后 Runtime 确认 | 终态 |
| - | `expired` | **取消**(合并入 retry 逻辑) | - | lockUntil 超时 → back to `pending`Reaper 处理) | - |
> **为什么 `processing` → `running` 而非 `locked`**:旧系统的 `processing` 表示 Worker 正在执行(`ai-analysis.worker.ts:48`),语义等同于新系统 heartbeat 确认后的 `running`。新系统的 `locked` 是「已被 claim 但尚未确认执行开始」的中间态,在旧系统中由 BullMQ 内部管理,无显式状态对应。
>
> **Reaper 安全影响**:若映射为 `locked`JobReaperService 会将旧 Worker 正在执行的任务视为「无 heartbeat 的 locked job」而在 `lockUntil` 过期后错误清扫回 `pending`,导致重复执行。映射为 `running` 可避免此问题Reaper 对 `running` 的清扫阈值远长于 `locked`)。
**迁移回填 SQL**(旧 `status` → 新 `lifecycleStatus`
```sql
UPDATE `AiAnalysisJob`
SET `lifecycleStatus` = CASE `status`
WHEN 'pending' THEN 'pending'
WHEN 'processing' THEN 'running'
WHEN 'completed' THEN 'succeeded'
WHEN 'failed' THEN 'failed'
ELSE `status`
END
WHERE `lifecycleStatus` IS NULL;
```
### 4. 新旧字段读写矩阵
| 场景 | 读取旧字段 | 写入新字段 | Shadow Write | 说明 |
|------|----------|----------|-------------|------|
| 旧 ProduceriOS active-recall | `status`, `jobType` | - | ✅ 改写到 `lifecycleStatus` | M-AI-04 迁移前,旧 Producer 仍写 `status` |
| 旧 WorkerAiAnalysisWorker | `status` | - | ✅ 改写到 `lifecycleStatus` | M-AI-04 迁移前,旧 Worker 仍读 `status` |
| 新 ProducerUserAiService | `lifecycleStatus` | 全部新字段 | ✅ 同步 `status` | 写新字段 + Shadow Write `status` |
| 新 WorkerAiJob Worker | 全部新字段 | 全部新字段 | ✅ 同步 `status` | M-AI-03 实现 |
| Admin 查询Dashboard/Metrics | `AiUsageLog`(不变) | - | ❌ | 不涉及 Job 表 |
| Admin 查询Learning | `AiAnalysisResult`(不变) | - | ❌ | 不涉及 Job 表 |
| iOS Job 列表 | `jobType`, `lifecycleStatus`, `createdAt` | - | ❌ | 新字段直接通过 API 返回 |
**Shadow Write 规则**M-AI-02-10 实现):
```typescript
// 旧 → 新reverseShadowMap旧代码写 status 时,同步写 lifecycleStatus
const reverseShadowMap: Record<string, string> = {
'pending': 'pending',
'processing': 'running', // 旧 processing = 正在执行 → 新 running
'completed': 'succeeded',
'failed': 'failed',
};
// 新 → 旧shadowMap新代码写 lifecycleStatus 时,同步写 status
const shadowMap: Record<string, string | null> = {
'pending': 'pending',
'locked': null, // 旧系统无对应,不写 status
'running': 'processing', // 新 running = 正在执行 → 旧 processing
'succeeded': 'completed',
'failed': 'failed',
'cancelled': 'failed', // 旧系统无 cancelled映射为 failed终态
};
```
1. 新代码写 `lifecycleStatus` 时,若 `shadowMap[lifecycleStatus]` 非 null同步写 `status`
2. 旧代码写 `status` 时,同步写 `lifecycleStatus = reverseShadowMap[status]`
3. `status` 废弃时机M-AI-05Active Recall + Feynman 全部迁移后)
### 5. 索引评估
| 索引 | 类型 | 用途 | 评估 |
|------|------|------|------|
| `(userId, jobType, createdAt)` | 复合 | iOS 用户端 Job 列表(最频繁查询) | ✅ **必须**:当前 `AiAnalysisJob` 仅单列 `userId` 索引 |
| `(lifecycleStatus, createdAt)` | 复合 | Worker pollWHERE status='pending' ORDER BY createdAt | ✅ **必须**Runtime/Worker 轮询核心查询 |
| `(queueName, lifecycleStatus, createdAt)` | 复合 | 按队列轮询ai-interactive / ai-background 分队列消费) | ✅ 重要M-AI-03 队列分离 |
| `(targetType, targetId)` | 复合 | 按学习对象查 Job如「该 KnowledgeBase 的所有分析」) | ✅ 保留:从 `AiRuntimeJob` 迁移 |
| `(parentJobId)` | 单列 | 查子 Job如 Quiz Generation 的拆分任务) | ✅ 必要:子 Job 关联 |
| `idempotencyKey` | UNIQUE单列 | 幂等保证 | ✅ 必要key 由客户端提供(如 iOS UUID复用 key 即表示重复请求应被拒绝。若未来需要 `(userId, idempotencyKey)` 复合唯一(防跨用户 key 碰撞),可新增复合索引并 drop 此单列索引 |
### 6. 删除策略
| 关系 | 当前 FK | 变更 | M-AI-02 后 FK | 说明 |
|------|---------|------|-------------|------|
| Job → Snapshot | 无 FK | 新增 FK`AiJobSnapshot.jobId → AiJob.id` | `ON DELETE CASCADE` | M-AI-02-05 实现。CASCADE 确保 Job 删除时无孤儿 Snapshot独立过期清理通过 `deleteExpired()` 在 CASCADE 前主动回收 |
| Job → Artifact | 无M-AI-02-06 新建) | 新增 FK`AiJobArtifact.jobId → AiJob.id` | `ON DELETE CASCADE` | Artifact 随 Job 级联删除 |
| Job → UsageLog | 无M-AI-02-08 新建) | 新增 FK`AiUsageLog.jobId → AiJob.id` | `ON DELETE SET NULL` | UsageLog 是审计记录,删除 Job 时保留 |
| Job → Child Job | 无M-AI-02-03 新增 `parentJobId` | 新增 FK`AiJob.parentJobId → AiJob.id` | `ON DELETE SET NULL` | 父 Job 删除时不级联删子 Job |
| User → Job | `ON DELETE RESTRICT` | 不变 | 保持 `RESTRICT` | 删除用户前必须清理 Job |
### 7. DDL 草图
```sql
-- M-AI-02-03: 身份与路由字段
ALTER TABLE `AiAnalysisJob`
ADD COLUMN `triggerType` VARCHAR(32) NOT NULL DEFAULT 'api' AFTER `jobType`,
ADD COLUMN `queueName` VARCHAR(64) NOT NULL DEFAULT 'ai-interactive' AFTER `triggerType`,
ADD COLUMN `targetType` VARCHAR(32) NULL AFTER `sessionId`,
ADD COLUMN `targetId` VARCHAR(255) NULL AFTER `targetType`,
ADD COLUMN `parentJobId` VARCHAR(255) NULL AFTER `targetId`,
ADD COLUMN `idempotencyKey` VARCHAR(255) NULL AFTER `parentJobId`,
ADD COLUMN `retriedFromJobId` VARCHAR(255) NULL AFTER `idempotencyKey`;
-- M-AI-02-04: 生命周期字段
ALTER TABLE `AiAnalysisJob`
ADD COLUMN `lifecycleStatus` VARCHAR(32) NOT NULL DEFAULT 'pending' AFTER `retriedFromJobId`,
ADD COLUMN `priority` INT NOT NULL DEFAULT 0 AFTER `lifecycleStatus`,
ADD COLUMN `lockedBy` VARCHAR(100) NULL AFTER `priority`,
ADD COLUMN `lockedAt` DATETIME(3) NULL AFTER `lockedBy`,
ADD COLUMN `lockUntil` DATETIME(3) NULL AFTER `lockedAt`,
ADD COLUMN `cancelRequestedAt` DATETIME(3) NULL AFTER `lockUntil`,
ADD COLUMN `attemptCount` INT NOT NULL DEFAULT 0 AFTER `progress`,
ADD COLUMN `maxAttempts` INT NOT NULL DEFAULT 3 AFTER `attemptCount`,
ADD COLUMN `timeoutMs` INT NOT NULL DEFAULT 120000 AFTER `maxAttempts`;
-- M-AI-02-04: 列重命名 1/2 — completedAt → finishedAt
-- 理由:与 startedAt 配对统一("started/finished"),旧名 "completed" 与新 lifecycleStatus 'succeeded' 语义冲突
-- 安全M-AI-02-01 审计确认测试文件 Raw SQL 不引用此列名,无破坏风险
ALTER TABLE `AiAnalysisJob`
CHANGE COLUMN `completedAt` `finishedAt` DATETIME(3) NULL;
-- M-AI-02-04: 输入输出字段
ALTER TABLE `AiAnalysisJob`
ADD COLUMN `inputRef` VARCHAR(255) NULL AFTER `timeoutMs`,
ADD COLUMN `inputSchemaVersion` VARCHAR(100) NULL AFTER `inputRef`,
ADD COLUMN `snapshotId` VARCHAR(255) NULL AFTER `inputSchemaVersion`,
ADD COLUMN `promptKey` VARCHAR(128) NULL AFTER `snapshotId`,
ADD COLUMN `promptVersion` VARCHAR(100) NULL AFTER `promptKey`,
ADD COLUMN `outputSchemaVersion` VARCHAR(100) NULL AFTER `promptVersion`;
-- M-AI-02-04: 凭据与模型字段
ALTER TABLE `AiAnalysisJob`
ADD COLUMN `credentialMode` VARCHAR(32) NOT NULL DEFAULT 'platform_key' AFTER `outputSchemaVersion`,
ADD COLUMN `credentialId` VARCHAR(255) NULL AFTER `credentialMode`,
ADD COLUMN `modelTier` VARCHAR(32) NOT NULL DEFAULT 'primary' AFTER `credentialId`,
ADD COLUMN `modelProvider` VARCHAR(32) NOT NULL DEFAULT 'deepseek' AFTER `modelTier`,
ADD COLUMN `modelName` VARCHAR(64) NOT NULL DEFAULT 'deepseek-chat' AFTER `modelProvider`;
-- M-AI-02-04: 错误与输出字段
ALTER TABLE `AiAnalysisJob`
ADD COLUMN `errorCode` VARCHAR(100) NULL AFTER `errorMessage`,
ADD COLUMN `publicErrorMessage` VARCHAR(500) NULL AFTER `errorCode`,
ADD COLUMN `outputHash` VARCHAR(255) NULL AFTER `publicErrorMessage`,
ADD COLUMN `validatedOutput` JSON NULL AFTER `outputHash`;
-- M-AI-02-04: 列重命名 2/2 — errorMessage → internalErrorMessage
-- 理由:新增 publicErrorMessage 列(面向用户),需区分内部/对外错误信息
-- 安全M-AI-02-01 审计确认测试文件 Raw SQL 不引用此列名,无破坏风险
ALTER TABLE `AiAnalysisJob`
CHANGE COLUMN `errorMessage` `internalErrorMessage` TEXT NULL;
-- 索引
ALTER TABLE `AiAnalysisJob`
ADD INDEX `idx_userId_jobType_createdAt` (`userId`, `jobType`, `createdAt`),
ADD INDEX `idx_lifecycleStatus_createdAt` (`lifecycleStatus`, `createdAt`),
ADD INDEX `idx_queueName_lifecycleStatus_createdAt` (`queueName`, `lifecycleStatus`, `createdAt`),
ADD INDEX `idx_targetType_targetId` (`targetType`, `targetId`),
ADD INDEX `idx_parentJobId` (`parentJobId`),
ADD UNIQUE INDEX `idx_userId_jobType_idempotencyKey` (`userId`, `jobType`, `idempotencyKey`);
```
### 8. 零停机发布流程
| 阶段 | 操作 | 影响 | 时长 |
|------|------|------|------|
| **1. Pre-deploy** | 执行 DDL所有新列 nullable 或有 default | 无锁表InnoDB Online DDL0.12 MB 表 < 10ms | < 1 |
| **2. Deploy V1** | 部署包含 `@@map("AiAnalysisJob")` 的新 Prisma client | 旧代码仍读 `AiAnalysisJob`,新代码读 `AiJob`,物理表不变 | 正常滚动部署 |
| **3. Backfill** | `UPDATE AiAnalysisJob SET lifecycleStatus = CASE status ...` | 9 行数据,瞬时完成 | < 1 |
| **4. Verify** | 运行 E2E smoke test | 确认新旧 Producer 均正常读写 | 5 分钟 |
| **5. Shadow Write** | 启用双向 Shadow Writestatus ↔ lifecycleStatus | 新旧代码互不阻塞 | 运行至 M-AI-05 |
| **6. Cleanup** | M-AI-05 删除旧 `status` 列、`sessionId``answerId` | 确认无旧代码读取后执行 | M-AI-05 批次 |
### 9. 回滚流程
| 场景 | 操作 |
|------|------|
| **阶段 1-2 回滚** | DDL 新增列全部 nullable旧代码不感知。无需 DDL 回滚,仅回滚代码部署。 |
| **阶段 3 回滚** | Backfill 仅写了 `lifecycleStatus`,不影响 `status` 列。回滚代码即可。 |
| **阶段 4-5 回滚** | Shadow Write 双向同步,两端状态一致。回滚代码后旧代码继续工作。 |
| **紧急回滚到 V0** | 无 DDL 回滚需求。所有新列为 nullable 或有 default。回滚代码部署即可。 |
| **`completedAt``finishedAt` 回滚** | `CHANGE COLUMN finishedAt completedAt DATETIME(3) NULL` — 仅改名,数据无损 |
### 10. 实体关系图
```
┌──────────┐ ┌──────────────────┐ ┌──────────────────┐
│ User │──1:N──│ AiJob │──1:N──│ AiJobArtifact │
│ │ │ (AiAnalysisJob) │ │ (M-AI-02-06) │
└──────────┘ └────────┬─────────┘ └──────────────────┘
┌───────────────┼───────────────┐
│ │ │
1:N 1:1 N:1
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ AiJobSnapshot│ │ AiUsageLog │ │ AiJob │
│(M-AI-02-05) │ │(M-AI-02-08) │ │ (parentJob) │
└──────────────┘ └──────────────┘ └──────────────┘
N:1 (logical, no FK)
┌──────────────┐
│ AiAnalysis- │ 旧系统 A保留至 M-AI-04
│ Result │
└──────────────┘
```
---
## 非目标
- 不创建新通用 Job 主表
- 不修改 `AiRuntimeJob` / `AiRuntimeResult`0 行M-AI-08 退场)
- 不修改 `AiAnalysisResult`(保留至 M-AI-04
- 不删除任何现有列(仅 rename `completedAt``finishedAt``errorMessage``internalErrorMessage`
- 不迁移历史数据9 行回填 trivial
## 验收
- [x] 包含最终 Prisma 草图§2.1
- [x] 包含 DDL 草图§7
- [x] 包含状态映射表§3
- [x] 包含新旧字段读写矩阵§4
- [x] 包含实体关系图§10
- [x] 包含零停机发布流程§8
- [x] 包含回滚流程§9
- [x] 索引评估§5
- [x] 删除策略§6
- [x] 基于 M-AI-02-01 审计结论MiniMax 已到期、数据量 9 行、FK 全 RESTRICT/CASCADE

View File

@ -0,0 +1,652 @@
# M-AI-02-01现有 AI Job Schema 审计
> 审计日期2026-06-20
> 审计人wangdl半自动化审计代码扫描 + 生产 MySQL 只读查询 via SSH 隧道)
---
## 1. Schema 逐字段审计
### 1.1 AiAnalysisJob旧系统 A
物理表名:`AiAnalysisJob` | 创建于:`prisma/migrations/20250516000000_init/migration.sql:300-319`
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiAnalysisJob_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
| 3 | `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | `AiAnalysisJob_sessionId_idx` | ❌ | - | - | - |
| 4 | `answerId` | `answerId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
| 5 | `jobType` | `jobType` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
| 6 | `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | `AiAnalysisJob_status_idx` | ❌ | - | - | - |
| 7 | `progress` | `progress` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 8 | `errorMessage` | `errorMessage` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
| 9 | `queuedAt` | `queuedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 10 | `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 11 | `completedAt` | `completedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 12 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
| 13 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
**Relation**`results``AiAnalysisResult[]`(一对多)
---
### 1.2 AiAnalysisResult旧系统 A 输出)
物理表名:`AiAnalysisResult` | 创建于:`prisma/migrations/20250516000000_init/migration.sql:322-342`
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiAnalysisResult_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
| 3 | `jobId` | `jobId` | VARCHAR(191) | ❌ | - | `AiAnalysisResult_jobId_idx` | ❌ | `AiAnalysisJob.id` | RESTRICT | CASCADE |
| 4 | `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | `AiAnalysisResult_sessionId_idx` | ❌ | - | - | - |
| 5 | `answerId` | `answerId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
| 6 | `summary` | `summary` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
| 7 | `masteryScore` | `masteryScore` | INT | ✅ | - | ❌ | ❌ | - | - | - |
| 8 | `strengths` | `strengths` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 9 | `weaknesses` | `weaknesses` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 10 | `suggestions` | `suggestions` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 11 | `nextActions` | `nextActions` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 12 | `rawResult` | `rawResult` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 13 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
| 14 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
**Relation**`job``AiAnalysisJob`(多对一)
---
### 1.3 AiRuntimeJob新系统 B
物理表名:`AiRuntimeJob` | 无独立 migration SQLFK 由 Prisma schema push 创建,生产已确认存在
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiRuntimeJob_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
| 3 | `jobType` | `jobType` | VARCHAR(64) | ❌ | - | `AiRuntimeJob_jobType_idx` | ❌ | - | - | - |
| 4 | `targetType` | `targetType` | VARCHAR(32) | ❌ | - | 复合 `AiRuntimeJob_targetType_targetId_idx` | ❌ | - | - | - |
| 5 | `targetId` | `targetId` | VARCHAR(255) | ❌ | - | 同上 | ❌ | - | - | - |
| 6 | `snapshotId` | `snapshotId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
| 7 | `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | `AiRuntimeJob_status_idx` | ❌ | - | - | - |
| 8 | `priority` | `priority` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 9 | `idempotencyKey` | `idempotencyKey` | VARCHAR(255) | ✅ | - | ❌ | ✅ | - | - | - |
| 10 | `apiKeyMode` | `apiKeyMode` | VARCHAR(32) | ❌ | `'platform_key'` | ❌ | ❌ | - | - | - |
| 11 | `credentialId` | `credentialId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
| 12 | `modelProvider` | `modelProvider` | VARCHAR(32) | ❌ | `'deepseek'` | ❌ | ❌ | - | - | - |
| 13 | `modelName` | `modelName` | VARCHAR(64) | ❌ | `'deepseek-chat'` | ❌ | ❌ | - | - | - |
| 14 | `promptVersion` | `promptVersion` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
| 15 | `outputSchemaVersion` | `outputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
| 16 | `attemptNo` | `attemptNo` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 17 | `retriedFromJobId` | `retriedFromJobId` | VARCHAR(255) | ✅ | - | ❌ | ❌ | - | - | - |
| 18 | `lockedBy` | `lockedBy` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
| 19 | `lockedAt` | `lockedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 20 | `lockUntil` | `lockUntil` | DATETIME(3) | ✅ | - | `AiRuntimeJob_lockUntil_idx` | ❌ | - | - | - |
| 21 | `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 22 | `finishedAt` | `finishedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 23 | `retryCount` | `retryCount` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 24 | `maxRetryCount` | `maxRetryCount` | INT | ❌ | `3` | ❌ | ❌ | - | - | - |
| 25 | `timeoutSeconds` | `timeoutSeconds` | INT | ❌ | `120` | ❌ | ❌ | - | - | - |
| 26 | `errorCode` | `errorCode` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
| 27 | `errorMessage` | `errorMessage` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
| 28 | `cancelRequestedAt` | `cancelRequestedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 29 | `cancelledAt` | `cancelledAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
| 30 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
| 31 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
**Relation**`result``AiRuntimeResult?`(一对一)
---
### 1.4 AiRuntimeResult新系统 B 输出)
物理表名:`AiRuntimeResult` | 无独立 migration SQLFK 由 Prisma schema push 创建,生产已确认存在
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
| 2 | `jobId` | `jobId` | VARCHAR(191) | ❌ | - | `AiRuntimeResult_jobId_idx` | ✅ | `AiRuntimeJob.id` | RESTRICT | CASCADE |
| 3 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiRuntimeResult_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
| 4 | `runtimeInstanceId` | `runtimeInstanceId` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
| 5 | `status` | `status` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
| 6 | `attemptNo` | `attemptNo` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 7 | `resultIdempotencyKey` | `resultIdempotencyKey` | VARCHAR(255) | ✅ | - | `AiRuntimeResult_resultIdempotencyKey_idx` | ❌ | - | - | - |
| 8 | `outputHash` | `outputHash` | VARCHAR(255) | ✅ | - | ❌ | ❌ | - | - | - |
| 9 | `rawOutput` | `rawOutput` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 10 | `validatedOutput` | `validatedOutput` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 11 | `schemaVersion` | `schemaVersion` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
| 12 | `validationErrors` | `validationErrors` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
| 13 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
| 14 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
---
### 1.5 AiUsageLog共享 AI 用量日志)
物理表名:`AiUsageLog` | 创建于:`prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql:9-30`FK 同文件 line 48
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiUsageLog_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
| 3 | `feature` | `feature` | VARCHAR(64) | ❌ | - | `AiUsageLog_feature_idx` | ❌ | - | - | - |
| 4 | `provider` | `provider` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
| 5 | `model` | `model` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
| 6 | `tier` | `tier` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
| 7 | `promptKey` | `promptKey` | VARCHAR(128) | ❌ | - | ❌ | ❌ | - | - | - |
| 8 | `promptVersion` | `promptVersion` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
| 9 | `inputTokens` | `inputTokens` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 10 | `outputTokens` | `outputTokens` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 11 | `estimatedCost` | `estimatedCost` | DOUBLE | ❌ | `0` | ❌ | ❌ | - | - | - |
| 12 | `latencyMs` | `latencyMs` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
| 13 | `success` | `success` | BOOLEAN | ❌ | `true` | ❌ | ❌ | - | - | - |
| 14 | `errorMessage` | `errorMessage` | VARCHAR(500) | ✅ | - | ❌ | ❌ | - | - | - |
| 15 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | `AiUsageLog_createdAt_idx` | ❌ | - | - | - |
---
### 1.6 FK 约束来源Prisma 隐式默认 vs 显式声明
全部 7 个 `@relation` 在 Prisma schema 中均**未显式声明** `onDelete` / `onUpdate`
```prisma
// 典型模式(无显式 onDelete/onUpdate
user User @relation(fields: [userId], references: [id])
job AiAnalysisJob @relation(fields: [jobId], references: [id])
```
生产数据库 `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS` 确认全部 7 个 FK 均为 `RESTRICT / CASCADE`,这与 Prisma 默认行为一致:
- Prisma 默认 `onDelete: Restrict`
- Prisma 默认 `onUpdate: Cascade`
**对 M-AI-02 的影响**
1. **M-AI-02-03/04 新增 FK 时应显式声明**:避免依赖 Prisma 默认值(不同版本/方言可能不同),在 schema 中显式写出 `onDelete: Restrict, onUpdate: Cascade` 以保持全表一致
2. **AiUsageLog.jobId FKM-AI-02-08**:新增 FK 应显式声明,**但需评估 `onDelete: SetNull` vs `Restrict`**——删除 Job 时 UsageLog 应保留以维持计费审计完整性
3. **OutboxEvent FKM-AI-02-07**:新增 Outbox 表的 FK 应显式声明,通常为 `onDelete: Cascade`Job 删除时级联删 Outbox
---
## 2. 状态值枚举
### 2.1 AiAnalysisJob.status
无 enum 定义,使用任意 string。实际使用值
| 值 | 使用位置 | 说明 |
|----|---------|------|
| `pending` | `ai-analysis.repository.ts:15` | 创建时默认 |
| `processing` | `ai-analysis.repository.ts:23`, `ai-analysis.worker.ts:48` | Worker 开始处理 |
| `completed` | `ai-analysis.repository.ts:24`, `ai-analysis.worker.ts:68` | Worker 处理成功 |
| `failed` | `ai-analysis.repository.ts:24`, `ai-analysis.worker.ts:102` | Worker 处理失败 |
| `queued` | `ai-analysis.service.ts:30,51` | API 返回给客户端的虚拟状态(不写入 DB |
### 2.2 AiRuntimeJob.status
无 enum 定义,使用任意 string。实际使用值
| 值 | 使用位置 | 说明 |
|----|---------|------|
| `pending` | `user-ai.service.ts:289,315,331`, `runtime-internal.service.ts:27,92,594` | 创建/重试回退 |
| `locked` | `runtime-internal.service.ts:99,123` | Runtime 获取锁 |
| `running` | `runtime-internal.service.ts:129,131`, `job-reaper.service.ts:40,54`, `platform-budget.service.ts:55` | 执行中 |
| `succeeded` | `runtime-internal.service.ts:268` | 执行成功 |
| `failed` | `runtime-internal.service.ts:605`, `job-reaper.service.ts:98`, `submitFailure()` | 执行失败/超时终止 |
| `cancelled` | `user-ai.service.ts:353`, `runtime-internal.service.ts:583` | 用户取消 |
| `expired` | `docs/ai-job-state-machine.md:23` | 文档定义,代码中未显式赋此值 |
### 2.3 AiRuntimeResult.status
| 值 | 说明 |
|----|------|
| `succeeded` | 最终成功(代码中 `runtime-internal.service.ts:249` 检查) |
| 其他值未明确定义 | `submitResult()` 接受任意 `dto.status` |
---
## 3. JobType 枚举
### 3.1 AiAnalysisJob.jobType旧系统 A
| 值 | 使用位置 | 说明 |
|----|---------|------|
| `active-recall` | `ai-analysis.service.ts:19` | 主动回忆分析 |
| `feynman-evaluation` | `ai-analysis.service.ts:40` | 费曼评估分析 |
### 3.2 AiRuntimeJob.jobType新系统 B
| 值 | 使用位置 | 说明 |
|----|---------|------|
| `learning_state_analysis` | `user-ai.dto.ts:64` | 学习状态分析 |
| `weak_point_analysis` | `user-ai.dto.ts:65` | 薄弱点分析 |
| `next_action_planning` | `user-ai.dto.ts:66` | 下一步建议 |
| `quiz_generation` | `user-ai.dto.ts:67` | 题目生成 |
| `flashcard_generation` | `user-ai.dto.ts:68` | 卡片生成 |
**关键发现**:两套系统的 jobType 互不重叠,不存在同名冲突。
---
## 4. 生产数据审计
> 查询时间2026-06-20 03:14 UTC
> 数据库zhixi_prod @ 120.53.227.155:3306蜂驰云 8C 生产服务器SSH 隧道连接)
> MySQL 版本8.0.46
> 查询脚本:`scripts/query-audit-data.ts`
### 4.1 AiAnalysisJob
| 指标 | 值 |
|------|-----|
| **总记录数** | 9 |
| 表大小 | 0.06 MB |
| 最早记录 | 2026-06-19T14:34:05.815Z |
| 最新记录 | 2026-06-19T14:48:10.489Z |
#### 状态分布
| 状态 | 数量 | 占比 |
|------|------|------|
| `completed` | 5 | 56% |
| `failed` | 4 | 44% |
#### jobType 分布
| jobType | 数量 |
|---------|------|
| `feynman-evaluation` | 7 |
| `active-recall` | 2 |
#### NULL 字段分布
| 字段 | NULL 数 | NULL 率 |
|------|---------|---------|
| `sessionId` | 9 | 100% |
| `answerId` | 7 | 78% |
| `errorMessage` | 5 | 56%4 failed jobs 均有 errorMessage |
| `queuedAt` | 0 | 0% |
| `startedAt` | 0 | 0% |
| `completedAt` | 0 | 0% — 正确。`AiAnalysisRepository.updateJobStatus()``completed`/`failed` 均显式设置 `completedAt = new Date()``ai-analysis.repository.ts:23-24`)。当前全部 9 行均为终态5 `completed` + 4 `failed`),无 `pending`/`processing` 滞留,故 0 NULL 符合预期。`DateTime?` 类型不生成 `DEFAULT CURRENT_TIMESTAMP`Prisma `@updatedAt` 也不影响此列。 |
#### 最大字段长度
| 字段 | 最大长度 |
|------|----------|
| `errorMessage` | 797 chars |
### 4.2 AiAnalysisResult
| 指标 | 值 |
|------|-----|
| **总记录数** | 5 |
| 表大小 | 0.06 MB |
| 孤儿 FKjobId 不存在) | 0 |
| 重复 (userId, jobId) | 0 |
#### 最大 JSON 字段长度
| 字段 | 最大长度 |
|------|----------|
| `summary` | 162 chars |
| `strengths` | 61 chars |
| `weaknesses` | 184 chars |
| `suggestions` | 367 chars |
| `nextActions` | 185 chars |
| `rawResult` | 1,048 chars |
#### 父 Job 状态分布
| 父 Job 状态 | Result 数量 |
|-----------|------------|
| `completed` | 5 |
> 结论:所有 5 个 Result 均属于 completed Job无 failed Job 产生 Result符合代码逻辑
#### NULL 字段
| 字段 | NULL 数 |
|------|---------|
| `sessionId` | 5 (100%) |
| `answerId` | 5 (100%) |
| `summary` / `masteryScore` / `strengths` / etc. | 0 |
### 4.3 AiRuntimeJob
| 指标 | 值 |
|------|-----|
| **总记录数** | **0** |
| 表大小 | 0.11 MB空表空间为 initial extent 分配) |
> **确认**ADR "唯一创建 AiRuntimeJob 的入口无人调用" 的断言正确。该表在生产中为零记录。
### 4.4 AiRuntimeResult
| 指标 | 值 |
|------|-----|
| **总记录数** | **0** |
| 表大小 | 0.08 MB空表 |
### 4.5 AiUsageLog
| 指标 | 值 |
|------|-----|
| **总记录数** | 110 |
| 表大小 | 0.11 MB |
| 最早记录 | 2026-05-28T13:02:12.261Z |
| 最新记录 | 2026-06-19T14:52:40.199Z |
| success=true | 30 (27.3%) |
| success=false | 80 (72.7%) |
| errorMessage NULL | 30仅 success=true 的日志无错误消息) |
#### feature 分布
| feature | 总数 | 成功 | 失败 | 成功率 |
|---------|------|------|------|--------|
| `learning-trend` | 70 | 11 | 59 | 15.7% |
| `knowledge-import` | 15 | 1 | 14 | 6.7% |
| `rag-chat` | 13 | 12 | 1 | 92.3% |
| `active-recall-analysis` | 7 | 1 | 6 | 14.3% |
| `feynman-evaluation` | 4 | 4 | 0 | 100% |
| `review-card-generation` | 1 | 1 | 0 | 100% |
#### provider 分布
| provider | 数量 |
|----------|------|
| `minimax` | 59 |
| `deepseek` | 51 |
#### model 分布
| model | 数量 |
|-------|------|
| `minimax-m2.7` | 59 | **全部来自已到期 API Key2026-06-06 到期)** |
| `deepseek-v4-pro` | 35 | 当前主力模型 |
| `deepseek-v4-flash` | 16 | 轻量兜底 |
#### AiUsageLog 成功率根因分析
107/110 的失败率72.7%)由以下因素叠加导致:
1. **MiniMax M2.7 API Key 已到期**2026-06-06 到期,见 devops `轻量云服务器凭据.md`
- `minimax-m2.7` 共 59 次调用,失败 59 次100% 失败率)
- 影响 feature`learning-trend`59 次全部 minimax`knowledge-import`14/15 次失败含大量 minimax
2. **`learning-trend`**70 次,成功率 16%)是后台定时任务 → 高调用量 × minimax 到期 = 大量失败
3. **`rag-chat`**13 次,成功率 92%)使用 `deepseek-v4-pro` → 几乎全部成功
4. **`feynman-evaluation`**4 次,成功率 100%)使用 `deepseek-v4-pro` → 全部成功
**结论**:排除 Minimax 到期影响后DeepSeek 成功率接近 100%。2026-06-06 后所有调用已切换至 DeepSeek V4 Pro/V4 Flash蜂驰云生产凭据确认M-AI-02 无需为 Minimax 保留兼容。
#### 孤儿外键
| 检测 | SQL | 结果 |
|------|-----|------|
| `AiAnalysisResult.jobId``AiAnalysisJob.id` | `SELECT COUNT(*) FROM AiAnalysisResult r LEFT JOIN AiAnalysisJob j ON r.jobId=j.id WHERE j.id IS NULL` | 0 |
| `AiAnalysisResult.userId``User.id` | 同上模式 | 0 |
| `AiAnalysisJob.userId``User.id` | 同上模式 | 0 |
| `AiUsageLog.userId``User.id` | 同上模式(有 FK RESTRICT不可能存在 | 0 |
#### 重复业务记录
**定义**:重复 = 同一用户对同一 jobType 在 **1 秒内** 创建 ≥2 条记录(并发创建)。
| 检测模式 | SQL | 结果 |
|---------|-----|------|
| `(userId, jobType)` 1秒并发 | `SELECT a.id,b.id FROM AiAnalysisJob a JOIN AiAnalysisJob b ON a.userId=b.userId AND a.jobType=b.jobType WHERE a.id<b.id AND ABS(TIMESTAMPDIFF(SECOND,a.createdAt,b.createdAt))<=1` | **1 对** |
| `(userId, sessionId, jobType)` 并发 | `GROUP BY userId,sessionId,jobType HAVING cnt>1 WHERE sessionId IS NOT NULL` | 0sessionId 100% NULL 导致) |
| `(userId, jobId)` 在 AiAnalysisResult | `GROUP BY userId,jobId HAVING cnt>1` | 0 |
| AiUsageLog 同秒双写 | `GROUP BY userId,feature,provider,model,createdAt HAVING cnt>1` | 0 |
**发现的并发重复详情**
```
id1=cmql14u46007nwfzktt0ttsn0 id2=cmql14uey007rwfzkl9uf0ssz
jobType=feynman-evaluation status=completed/completed
createdAt=2026-06-19T14:34:05Z / 14:34:06Z diff=0s
```
**评估**:两个 Job 均在 1 秒内创建jobType 相同(`feynman-evaluation`),最终状态均为 `completed`。这是 `AiAnalysisService.evaluateFeynman()` 的并发调用导致。**AiAnalysisJob 表无 (userId, sessionId, jobType) 唯一约束**无法在数据库层防御并发重复。M-AI-02-03 引入 `idempotencyKey` 后可通过业务层幂等解决。
#### JSON/TEXT 字段最大容量
**检测方法**`SELECT MAX(LENGTH(column)) FROM table`
| 表 | 字段 | 类型 | 当前最大 | 检测 SQL |
|----|------|------|---------|----------|
| `AiAnalysisJob` | `errorMessage` | TEXT | **797 chars** | `SELECT MAX(LENGTH(errorMessage)) FROM AiAnalysisJob` |
| `AiAnalysisResult` | `rawResult` | JSON | **1,048 chars** | `SELECT MAX(LENGTH(rawResult)) FROM AiAnalysisResult` |
| `AiAnalysisResult` | `strengths` | JSON | 61 | 同上 |
| `AiAnalysisResult` | `weaknesses` | JSON | 184 | 同上 |
| `AiAnalysisResult` | `suggestions` | JSON | **367 chars** | 同上 |
| `AiAnalysisResult` | `nextActions` | JSON | 185 | 同上 |
| `AiAnalysisResult` | `summary` | TEXT | 162 | 同上 |
| `AiRuntimeJob` | `errorMessage` | TEXT | 0零记录 | `SELECT MAX(LENGTH(errorMessage)) FROM AiRuntimeJob` |
| `AiRuntimeResult` | `rawOutput` | JSON | NULL零记录 | `SELECT MAX(LENGTH(rawOutput)) FROM AiRuntimeResult` |
| `AiRuntimeResult` | `validatedOutput` | JSON | NULL零记录 | 同上 |
**M-AI-02-04 容量建议**
- 当前 JSON/TEXT 字段均在 1KB 级别,远低于 MEDIUMTEXT16MB上限
- `rawResult` 最大 1KB → M-AI-02-04 新增的 `AiJobSnapshot` 需要容纳完整的 snapshot JSON约 5-10KB建议 `MEDIUMTEXT``JSON`MySQL JSON 列上限 1GB但索引友好
- `AiAnalysisJob.errorMessage` 最大 797 chars → 新建 `AiJob.errorMessage` 保持 `TEXT`64KB即可无需 `MEDIUMTEXT`
---
## 5. 调用方完整清单
### 5.1 `prisma.aiAnalysisJob` 直接访问
| 文件 | 行号 | 操作 | 说明 |
|------|------|------|------|
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 9 | `create` | 创建 Job |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 26 | `update` | 更新 Job 状态 |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 30 | `findUnique` | 查询 Jobinclude results |
### 5.2 `prisma.aiAnalysisResult` 直接访问
| 文件 | 行号 | 操作 | 说明 |
|------|------|------|------|
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 37 | `create` | 创建分析结果 |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 53 | `findUnique` | 查询分析结果 |
| `src/modules/learning-session/admin-learning.service.ts` | 229 | `findMany` | Admin 分页查询 |
| `src/modules/learning-session/admin-learning.service.ts` | 233 | `count` | Admin 总数 |
### 5.3 `prisma.aiRuntimeJob` 直接访问
| 文件 | 行号 | 操作 | 说明 |
|------|------|------|------|
| `src/modules/ai-runtime/user-ai.service.ts` | 226 | `findUnique` | 幂等检查 |
| `src/modules/ai-runtime/user-ai.service.ts` | 282 | `create` | 创建 Job |
| `src/modules/ai-runtime/user-ai.service.ts` | 344 | `findFirst` | 取消查询 |
| `src/modules/ai-runtime/user-ai.service.ts` | 351,359 | `update` | 取消更新 |
| `src/modules/ai-runtime/user-ai.service.ts` | 429 | `findFirst` | 状态查询 |
| `src/modules/ai-runtime/user-ai.service.ts` | 448 | `findMany` | 列表查询 |
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 36 | `findMany` | Poll 查询 |
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 89 | `updateMany` | Lock |
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 123,129 | `updateMany` | Heartbeat |
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 139,154,202,225,576,661 | `findUnique` | Snapshot/Result/Fail 查询 |
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 192,268,583,594,605 | `update` | Snapshot/Result/Fail 更新 |
| `src/modules/ai-runtime/job-reaper.service.ts` | 28 | `updateMany` | 清扫过期锁 |
| `src/modules/ai-runtime/job-reaper.service.ts` | 39,69 | `findMany` | 查询 stuck/expired |
| `src/modules/ai-runtime/job-reaper.service.ts` | 53,82,96 | `updateMany` | 清扫失败/过期 |
| `src/modules/ai-runtime/platform-budget.service.ts` | 53 | `count` | 平台预算检查 |
### 5.4 `prisma.aiUsageLog` 直接访问
| 文件 | 行号 | 操作 | 说明 |
|------|------|------|------|
| `src/modules/ai/usage/ai-usage-log.service.ts` | 28 | `create` | 写日志 |
| `src/modules/admin-costs/cost-aggregation.service.ts` | 31 | `findMany` | 成本聚合 |
| `src/modules/admin-metrics/admin-metrics.controller.ts` | 56 | `findMany` | AI 耗时统计 |
| `src/modules/admin-dashboard/admin-dashboard.service.ts` | 46 | `count` | 今日 AI 调用数 |
| `src/modules/admin-costs/admin-costs.service.ts` | 85 | `groupBy` | 成本统计 |
| `src/modules/learning-session/admin-learning.service.ts` | 247,251 | `findMany`/`count` | Admin 查询 |
### 5.5 间接调用方(通过 Repository/Service
| 调用方 | 文件 | 说明 |
|--------|------|------|
| `AiAnalysisWorker.process()` | `src/workers/ai-analysis.worker.ts:48-102` | 通过 `AiAnalysisRepository` 更新状态和结果 |
| `AiAnalysisService.analyze()` | `src/modules/ai-analysis/ai-analysis.service.ts:12-31` | 创建 Job 并入队 |
| `AiAnalysisService.evaluateFeynman()` | `src/modules/ai-analysis/ai-analysis.service.ts:33-52` | 创建 Job 并入队 |
| `AiAnalysisService.getJobStatus()` | `src/modules/ai-analysis/ai-analysis.service.ts:54-67` | 查询状态(序列化给客户端) |
| `AdminLearningService.getAnalysis()` | `src/modules/learning-session/admin-learning.service.ts:223-236` | Admin 分页查询 |
| `AdminLearningService.getAiUsage()` | `src/modules/learning-session/admin-learning.service.ts:240-254` | Admin 分页查询 |
| `AiUsageLogService.log()` | `src/modules/ai/usage/ai-usage-log.service.ts:26-32` | 写用量日志 |
| `CostAggregationService.aggregateToday()` | `src/modules/admin-costs/cost-aggregation.service.ts:27-59` | 成本聚合 |
| `AdminMetricsController.aiMetrics()` | `src/modules/admin-metrics/admin-metrics.controller.ts:54-89` | AI 耗时统计 |
| `AdminDashboardService.getStats()` | `src/modules/admin-dashboard/admin-dashboard.service.ts:46` | 仪表盘统计 |
---
## 6. 测试文件引用
| 文件 | 引用方式 | 影响 |
|------|---------|------|
| `test/helpers/integration-harness.ts:228` | 硬编码表名 `'AiAnalysisResult'`, `'AiAnalysisJob'` | **重命名 Prisma model 后需同步** |
| `src/modules/ai-runtime/user-ai.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关mock |
| `src/modules/ai-runtime/job-reaper.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关mock |
| `src/modules/ai-runtime/platform-budget.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关mock |
| `src/modules/ai-runtime/internal/runtime-internal.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关mock |
| `test/api-e2e.worker-int-spec.ts` | Raw SQL 8 处 + 字符串引用 8 处 = **16 处**引用物理表名 `AiAnalysisJob` / `AiAnalysisResult` | M-AI-02-09 重命名后需全部同步 | SQL: 91,101,107,197,210,218,252,255字符串: 88,105,108,195,215,219,253,256 |
| `test/worker-integration.worker-int-spec.ts` | Raw SQL 13 处 + 字符串引用 8 处 = **21 处**引用物理表名 | M-AI-02-09 重命名后需全部同步 | SQL: 113,120,127,158,170,212,245,259,270,282,299,313,316字符串: 110,114,118,121,125,247,314,317 |
---
## 7. 兼容风险分析
### 7.1 Model 名依赖
**风险等级:中等**
以下代码依赖 Prisma model 名 `AiAnalysisJob``AiAnalysisResult`
| 依赖点 | 文件 | 行号 | 影响 |
|--------|------|------|------|
| `prisma.aiAnalysisJob` | `ai-analysis.repository.ts` | 9,26,30 | 重命名 model 会导致编译错误 |
| `prisma.aiAnalysisResult` | `ai-analysis.repository.ts` | 37,53 | 同上 |
| `prisma.aiAnalysisResult` | `admin-learning.service.ts` | 229,233 | 同上 |
| 硬编码表名字符串 | `test/helpers/integration-harness.ts` | 228 | 清理表时使用物理表名 `'AiAnalysisJob'``'AiAnalysisResult'` |
**注意**`prisma.aiRuntimeJob``prisma.aiUsageLog` 不会被 M-AI-02 重命名(当前范围仅统一 `AiAnalysisJob → AiJob`)。
### 7.2 状态 enum 依赖
**风险等级:低**
- 所有状态值均为 raw string无 enum 定义
- `AiAnalysisJob` 使用 `pending`/`processing`/`completed`/`failed`
- `AiRuntimeJob` 使用 `pending`/`locked`/`running`/`succeeded`/`failed`/`cancelled`
- 两套状态值语义不同M-AI-02-04 需要统一
- 当前代码中没有任何 `AiAnalysisJobStatus` type/enum 定义
### 7.3 API 直接序列化数据库状态
**风险等级:中等**
| API | 文件 | 行号 | 序列化字段 |
|-----|------|------|----------|
| `AiAnalysisService.getJobStatus()` | `ai-analysis.service.ts:54-67` | 直接透传 `job.status``job.jobType``job.queuedAt``job.startedAt``job.completedAt` | **字段名变更会影响 iOS** |
| `UserAiService.getJob()` | `user-ai.service.ts:428-443` | 选择字段直接序列化 `jobType``status``errorCode``errorMessage` 等 | **字段名变更会影响 iOS** |
| `UserAiService.listJobs()` | `user-ai.service.ts:445-459` | 同上 | 同上 |
| `UserAiService.createAnalysisJob()` 返回值 | `user-ai.service.ts:340` | `{ jobId, status, createdAt }` | 不受影响(仅返回 ID+状态) |
### 7.4 Relation 重命名影响
**风险等级:低**
- `AiAnalysisJob.results``AiAnalysisResult[]`(系统 A
- `AiAnalysisResult.job``AiAnalysisJob`(系统 A
- `AiRuntimeJob.result``AiRuntimeResult?`(系统 B
- `AiRuntimeResult.job``AiRuntimeJob`(系统 B
- M-AI-02-09 将 `AiAnalysisJob` 重命名为 `AiJob` 时,`AiAnalysisResult.job` 的 relation 类型需要同时更新
### 7.5 Raw SQL / 手写表名
**风险等级:中高**
- 源文件(`src/`)中无 `$queryRaw`/`$executeRaw` 引用 AI 分析相关表
- `worker.main.ts:24``system.controller.ts:35` 仅使用 `SELECT 1` 做健康检查
- **重要发现**E2E 测试文件使用 Raw SQL 直接查询物理表:
- `test/api-e2e.worker-int-spec.ts`8 处 Raw SQL + 8 处字符串引用it/console.log= **16 处**硬编码表名 `'AiAnalysisJob'``'AiAnalysisResult'`
- `test/worker-integration.worker-int-spec.ts`13 处 Raw SQL + 8 处字符串引用 = **21 处**同上
- **合计 37 处引用**,其中 21 处为 Raw SQL`SELECT ... FROM AiAnalysisJob`16 处为字符串引用
- **M-AI-02-09 重命名物理表后,这 37 处引用必须全部同步更新**
### 7.6 报表/Admin 直接查询物理表
**风险等级:中等**
所有 Admin 查询通过 Prisma Client不会直接访问物理表。但以下聚合逻辑依赖现有字段
| Admin 功能 | 文件 | 依赖字段 |
|-----------|------|---------|
| Dashboard 今日 AI 调用数 | `admin-dashboard.service.ts:46` | `AiUsageLog.createdAt` |
| AI 耗时统计 | `admin-metrics.controller.ts:56-86` | `AiUsageLog.provider``model``latencyMs``success``estimatedCost` |
| 成本聚合 | `cost-aggregation.service.ts:31-56` | `AiUsageLog.*` |
| Admin 分析结果 | `admin-learning.service.ts:229-233` | `AiAnalysisResult.*` |
### 7.7 跨表数据关系
| 源表 | 目标表 | 关系 | 当前状态 | M-AI-02 影响 |
|------|--------|------|----------|-------------|
| `AiAnalysisJob` | `AiAnalysisResult` | 1:N FK | 生产运行中 | 需保留 |
| `AiAnalysisJob` | `FocusItem` | 逻辑关联Worker 进程内直接调用 `FocusItemsService.create()` | 生产运行中 | 不直接受影响 |
| `AiAnalysisJob` | `ReviewCard` | 逻辑关联EventEmitter → Subscriber → AI Workflow → `ReviewCard` 表) | 生产运行中 | **M-AI-05 Outbox 替代时必须保持此链** |
| `AiRuntimeJob` | `AiRuntimeResult` | 1:1 FK | 闲置(无生产者) | M-AI-03 规划使用 |
| `AiRuntimeJob` | `LearningAnalysisSnapshot` | 逻辑关联snapshotId | 闲置 | M-AI-03 规划使用 |
| `AiRuntimeJob` | `AiLearningAnalysis` | 逻辑关联jobId | 闲置 | M-AI-04 规划使用 |
| `AiRuntimeJob` | `QuestionGenerationPlan` | 逻辑关联jobId | 闲置 | M-AI-06 规划使用 |
| `AiRuntimeJob` | `FlashcardGenerationPlan` | 逻辑关联jobId | 闲置 | M-AI-06 规划使用 |
| `AiUsageLog` | 无 FK 到 Job 表 | **无关联** | - | **M-AI-02-08 需新增** |
### `ai.analysis.completed` 事件订阅链完整追踪
**对 M-AI-05Outbox 替代 EventEmitter至关重要。**
```
AiAnalysisWorker.process() // src/workers/ai-analysis.worker.ts:72
└─ eventBus.publish(new AIAnalysisCompleted(...)) // 发布事件
└─ ReviewCardSubscriber.handleAIAnalysisCompleted() // src/modules/review/review-card.subscriber.ts:11
@OnEvent('ai.analysis.completed')
│ 订阅 payload: { userId, jobId, sessionId?, answerId?, type, score?, analysis? }
└─ ReviewService.generateCards(userId, input) // src/modules/review/review.service.ts:68
│ 从 payload.analysis 中提取 weaknesses/strengths/summary
│ 拼接为 prompt 文本 → CardGenerationWorkflow.execute()
└─ ReviewRepository.insertCard(data) // src/modules/review/review.repository.ts:38
│ this.prisma.reviewCard.create({ data })
└─ 写入 ReviewCard 表 (prisma/schema.prisma:642-668)
字段: userId, frontText, backText, difficulty, status,
intervalDays, easeFactor, repetitionCount, lapseCount,
scheduleState, nextReviewAt
```
**关键发现**
1. `ReviewCard` **无 `jobId` FK** — 无法溯源哪次 AI 分析生成了哪张卡片
2. Subscriber 依赖进程内 EventEmitter — M-AI-05 改为 Outbox 后,此链必须保持
3. `FocusItem` 不走事件订阅 — Worker 进程内直接调 `focusItems.create()``ai-analysis.worker.ts:88`),不存在可靠投递保证
4. Subscriber 声明的 payload 接口(`review-card.subscriber.ts:12-20`)与实际 `AIAnalysisCompleted` event 类(`ai-analysis.worker.ts:13-16`)完全耦合 — 任何 event schema 变更需同步修改 subscriber
---
## 8. 关键发现总结
1. **生产数据量极小**AiAnalysisJob 仅 9 行AiAnalysisResult 仅 5 行AiUsageLog 110 行。所有表 < 0.12 MB。**Migration 锁表风险极低**毫秒级 ALTER TABLE)。
2. **AiRuntimeJob/AiRuntimeResult 零记录**:确认 ADR 断言正确——系统 B 无生产者活动。M-AI-02 Expand 无需回填历史 RuntimeJob 数据。
3. **两套系统并存**`AiAnalysisJob`生产运行中active-recall + feynman`AiRuntimeJob`闲置5 种分析类型)是两套完全独立的表,无任何 FK/逻辑关联
4. **AiUsageLog 与 Job 表无关联**:当前 `AiUsageLog``jobId` 字段,无法追踪单次 AI 调用归属。**M-AI-02-08 需要新增 `jobId` FK**
5. **无 Outbox/事件溯源**:系统 A 通过 NestJS EventEmitter 发布事件(`ai.analysis.completed`),系统 B 在 API 进程内同步写 Notification——无持久化事件表
6. **状态值非标准化**:无 enum 定义,两套状态语义不兼容(`processing` vs `locked`/`running``completed` vs `succeeded`
7. **AiRuntimeJob 字段远多于 AiAnalysisJob**31 vs 13 字段M-AI-02 Expand 后应保持一致
8. **无 raw SQL 引用 AI 表**AI 表仅通过 Prisma Client 访问
9. **37 处测试文件硬编码表名**`test/api-e2e.worker-int-spec.ts`(16) 和 `test/worker-integration.worker-int-spec.ts`(21) 中 Raw SQL 及字符串引用物理表名 `'AiAnalysisJob'`/`'AiAnalysisResult'`M-AI-02-09 重命名后必须全部同步。另有 `test/helpers/integration-harness.ts:228` 的清理列表
10. **sessionId/answerId 高 NULL 率**AiAnalysisJob 中 sessionId 100% NULLanswerId 78% NULL——M-AI-02-04 可评估是否保留这两个字段
11. **无孤儿 FK**:所有 FK 引用一致,数据完整性良好
12. **全部 FK 来自 Prisma 默认值**Prisma schema 中 7 个 `@relation` 均未显式声明 `onDelete`/`onUpdate`,生产实际值为 RESTRICT/CASCADEPrisma 默认。M-AI-02-03/04 新增 FK 应**显式声明**以避免跨版本/跨方言歧义(见 1.6 节)
13. **ReviewCard 无 jobId 溯源**`ai.analysis.completed``ReviewCardSubscriber` → AI Workflow → `ReviewCard` 的完整链路已追踪(见 7.7 节),但 ReviewCard 表无 `jobId` FK无法溯源卡片生成来源
14. **FocusItem 不走事件系统**Worker 进程内直接同步调用 `focusItems.create()`无可靠投递保证——M-AI-05 Outbox 化时需考虑
15. **AiUsageLog 低成功率根因已定位**72.7% 失败由 MiniMax M2.7 API Key 到期2026-06-06导致 — `learning-trend` 全部使用 minimax 故失败率 84%。DeepSeek 调用近乎 100% 成功。生产已全量切换至 DeepSeek V4 Pro/FlashM-AI-02 无需保留 Minimax 兼容