# 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 迁移后评估删除 |
| 列重命名策略 | **Prisma 逻辑名用新名，物理列用 `@map` 保持旧名** | `internalErrorMessage @map("errorMessage")`、`finishedAt @map("completedAt")`。旧版本应用通过物理列名兼容，新代码通过逻辑名访问 |

### 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 枚举（8 个值）：

| 状态 | 含义 |
|------|------|
| `created` | Job 记录已创建，尚未入队 |
| `queued` | 已入队，等待 Worker 消费 |
| `running` | Worker 正在执行 |
| `retrying` | 执行失败，等待重试 |
| `succeeded` | 执行成功（终态） |
| `failed` | 执行失败，超过最大重试（终态） |
| `cancel_requested` | 用户/Admin 请求取消，等待 Worker 确认 |
| `cancelled` | 已取消（终态） |

**Legacy → 统一映射**（Shadow Write + 回填）：

| 旧 AiAnalysisJob.status | 旧 AiRuntimeJob.status | 新 lifecycleStatus |
|------------------------|----------------------|-------------------|
| `pending` | `pending` | **`queued`** |
| `processing` | `running` | **`running`** |
| `completed` | `succeeded` | **`succeeded`** |
| `failed` | `failed` | **`failed`** |
| - | `locked` | N/A（新系统内部状态，旧系统无对应） |
| - | `cancelled` | **`cancelled`** |

> **为什么 `pending` → `queued`**：旧系统的 `pending` 表示「已创建且在 BullMQ 队列中等待」，语义更接近新系统的 `queued`。新系统的 `created` 预留给未来「创建记录」和「入队」分离的场景。

> **`lifecycleStatus` 过渡期 nullable**：旧代码写入时通过 Shadow Write 同步填充；M-AI-02-10 回填历史 NULL；M-AI-05 切换完成后改为 NOT NULL。

**迁移回填 SQL**（旧 `status` → 新 `lifecycleStatus`）：

```sql
UPDATE `AiAnalysisJob`
SET `lifecycleStatus` = CASE `status`
  WHEN 'pending'    THEN 'queued'
  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<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-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）
