# 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)