api-server/docs/architecture/adr-002-ai-job-database-expand.md
wangdl 800ce4a2b2 feat(M-AI-02): schema audit (02-01) + ADR freeze (02-02)
M-AI-02-01: Production DB audit of all 5 AI tables (9 rows, 0 orphans, 37 test refs)
M-AI-02-02: Frozen AiJob Expand ADR — 43-column schema, 6 indexes, state mapping

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 12:24:16 +08:00

419 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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