From 800ce4a2b2ecf2e5cb3a4e1116d9eb7a291773bb Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 20 Jun 2026 12:24:16 +0800 Subject: [PATCH] feat(M-AI-02): schema audit (02-01) + ADR freeze (02-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + .../adr-002-ai-job-database-expand.md | 418 +++++++++++ .../m-ai-02-current-schema-audit.md | 652 ++++++++++++++++++ 3 files changed, 1073 insertions(+) create mode 100644 docs/architecture/adr-002-ai-job-database-expand.md create mode 100644 docs/architecture/m-ai-02-current-schema-audit.md diff --git a/.gitignore b/.gitignore index 453f261..bac1cba 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/architecture/adr-002-ai-job-database-expand.md b/docs/architecture/adr-002-ai-job-database-expand.md new file mode 100644 index 0000000..bdae5e5 --- /dev/null +++ b/docs/architecture/adr-002-ai-job-database-expand.md @@ -0,0 +1,418 @@ +# ADR-002:AiJob 数据库 Expand — Schema 冻结 + +## 状态 + +已接受(2026-06-20) + +## 背景 + +M-AI-02-01 审计确认: + +- **AiAnalysisJob**(系统 A,13 字段,9 行生产数据):承载 active-recall 和 feynman-evaluation 分析 +- **AiRuntimeJob**(系统 B,31 字段,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) // 旧系统 A,100% NULL in prod + answerId String? @db.VarChar(191) // 旧系统 A,78% 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-05(Active 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 DTO(iOS 提供 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) | 保留但废弃(旧系统 A),M-AI-05 删除 | +| `answerId` | `answerId` | VARCHAR(191) | ✅ | - | 78% (7/9) | 保留但废弃(旧系统 A),M-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 | 说明 | +|------|----------|----------|-------------|------| +| 旧 Producer(iOS active-recall) | `status`, `jobType` | - | ✅ 改写到 `lifecycleStatus` | M-AI-04 迁移前,旧 Producer 仍写 `status` | +| 旧 Worker(AiAnalysisWorker) | `status` | - | ✅ 改写到 `lifecycleStatus` | M-AI-04 迁移前,旧 Worker 仍读 `status` | +| 新 Producer(UserAiService) | `lifecycleStatus` | 全部新字段 | ✅ 同步 `status` | 写新字段 + Shadow Write `status` | +| 新 Worker(AiJob 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 = { + 'pending': 'pending', + 'processing': 'running', // 旧 processing = 正在执行 → 新 running + 'completed': 'succeeded', + 'failed': 'failed', +}; + +// 新 → 旧(shadowMap):新代码写 lifecycleStatus 时,同步写 status +const shadowMap: Record = { + '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-05(Active Recall + Feynman 全部迁移后) + +### 5. 索引评估 + +| 索引 | 类型 | 用途 | 评估 | +|------|------|------|------| +| `(userId, jobType, createdAt)` | 复合 | iOS 用户端 Job 列表(最频繁查询) | ✅ **必须**:当前 `AiAnalysisJob` 仅单列 `userId` 索引 | +| `(lifecycleStatus, createdAt)` | 复合 | Worker poll(WHERE 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 DDL,0.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 Write(status ↔ 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) diff --git a/docs/architecture/m-ai-02-current-schema-audit.md b/docs/architecture/m-ai-02-current-schema-audit.md new file mode 100644 index 0000000..e6b3c5f --- /dev/null +++ b/docs/architecture/m-ai-02-current-schema-audit.md @@ -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 SQL,FK 由 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 SQL,FK 由 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 FK(M-AI-02-08)**:新增 FK 应显式声明,**但需评估 `onDelete: SetNull` vs `Restrict`**——删除 Job 时 UsageLog 应保留以维持计费审计完整性 +3. **OutboxEvent FK(M-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 | +| 孤儿 FK(jobId 不存在) | 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 Key(2026-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.id1 WHERE sessionId IS NOT NULL` | 0(sessionId 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 级别,远低于 MEDIUMTEXT(16MB)上限 +- `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` | 查询 Job(include 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-05(Outbox 替代 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% NULL,answerId 78% NULL——M-AI-02-04 可评估是否保留这两个字段 +11. **无孤儿 FK**:所有 FK 引用一致,数据完整性良好 +12. **全部 FK 来自 Prisma 默认值**:Prisma schema 中 7 个 `@relation` 均未显式声明 `onDelete`/`onUpdate`,生产实际值为 RESTRICT/CASCADE(Prisma 默认)。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/Flash,M-AI-02 无需保留 Minimax 兼容