api-server/docs/architecture/adr-002-ai-job-database-expand.md
wangdl 4c2b8d02d7
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 24s
docs(M-AI-02-GATE): update ADR-002 lifecycle spec + @map strategy
- lifecycleStatus enum: created|queued|running|retrying|succeeded|failed|cancel_requested|cancelled
- Legacy mapping: pending→queued, processing→running, completed→succeeded, failed→failed
- @map approach documented for physical column compatibility

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

432 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 迁移后评估删除 |
| 列重命名策略 | **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) // 旧系统 A100% NULL in prod
answerId String? @db.VarChar(191) // 旧系统 A78% NULL in prod
status String? @db.VarChar(32) // 🔴 @deprecated旧系统 A 状态列。过渡期 Shadow Write 使用M-AI-05 移除
// ── 时间戳 ──
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// ── 关联 ──
user User @relation(fields: [userId], references: [id], onDelete: Restrict, onUpdate: Cascade)
parentJob AiJob? @relation("JobChildren", fields: [parentJobId], references: [id], onDelete: SetNull, onUpdate: Cascade)
children AiJob[] @relation("JobChildren")
snapshots AiJobSnapshot[]
artifacts AiJobArtifact[]
usageLogs AiUsageLog[]
@@map("AiAnalysisJob")
@@index([userId, jobType, createdAt])
@@index([lifecycleStatus, createdAt])
@@index([queueName, lifecycleStatus, createdAt])
@@index([targetType, targetId])
@@index([parentJobId])
@@index([userId, jobType, idempotencyKey])
}
```
> **过渡期 `status` 列说明**`status` 是物理表 `AiAnalysisJob` 中已存在 13 个月之久的列(`VARCHAR(32) NOT NULL DEFAULT 'pending'`),旧代码 `AiAnalysisRepository.updateJobStatus()` 直接读写此列。M-AI-02-09 将 Prisma model 重命名为 `AiJob` 后,**必须在过渡期模型中保留 `status` 字段**(标记 `@deprecated`),否则 Shadow Write 机制§4缺少 Prisma 层面的实现路径。`status` 列在 M-AI-05Active Recall + Feynman 全部迁移完成后)通过 migration 删除。
#### 2.2 字段逐一定义
##### 身份与路由M-AI-02-03
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 | 未来删除阶段 |
|------------|---------|------|----------|---------|-------|---------|-----------|------------|
| `jobType` | `jobType` | VARCHAR(64) | ❌ | - | 复合 | API → Controller → Service | `AiAnalysisJob.jobType`expand 32→64 | - |
| `triggerType` | `triggerType` | VARCHAR(32) | ❌ | `'api'` | ❌ | Service 层根据调用方设定 | 新增 | - |
| `queueName` | `queueName` | VARCHAR(64) | ❌ | `'ai-interactive'` | 复合 | Service 层根据 jobType 路由 | 新增 | - |
| `targetType` | `targetType` | VARCHAR(32) | ✅ | - | 复合 | API DTO | `AiRuntimeJob.targetType` | - |
| `targetId` | `targetId` | VARCHAR(255) | ✅ | - | 复合 | API DTO | `AiRuntimeJob.targetId` | - |
| `parentJobId` | `parentJobId` | VARCHAR(255) | ✅ | - | ✅ 单列 | Service → 子 Job 创建时赋值 | 新增 | - |
| `idempotencyKey` | `idempotencyKey` | VARCHAR(255) | ✅ | - | ✅ UNIQUE | API DTOiOS 提供 UUID | `AiRuntimeJob.idempotencyKey` | - |
| `retriedFromJobId` | `retriedFromJobId` | VARCHAR(255) | ✅ | - | ❌ | Worker → 重试时赋值 | `AiRuntimeJob.retriedFromJobId` | M-AI-08 |
##### 生命周期与执行M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 | 未来删除阶段 |
|------------|---------|------|----------|---------|-------|---------|-----------|------------|
| `lifecycleStatus` | `lifecycleStatus` | VARCHAR(32) | ❌ | `'pending'` | ✅ 复合 | Worker/Reaper/User cancel | `AiAnalysisJob.status` + `AiRuntimeJob.status`(统一状态枚举) | - |
| `priority` | `priority` | INT | ❌ | `0` | ❌ | PriorityRulesService | `AiRuntimeJob.priority` | - |
| `progress` | `progress` | INT | ❌ | `0` | ❌ | Worker heartbeat | `AiAnalysisJob.progress` | - |
| `lockedBy` | `lockedBy` | VARCHAR(100) | ✅ | - | ❌ | Runtime lock | `AiRuntimeJob.lockedBy` | - |
| `lockedAt` | `lockedAt` | DATETIME(3) | ✅ | - | ❌ | Runtime lock | `AiRuntimeJob.lockedAt` | - |
| `lockUntil` | `lockUntil` | DATETIME(3) | ✅ | - | ❌ | Runtime heartbeat | `AiRuntimeJob.lockUntil` | - |
| `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | Worker/Runtime → 首次 heartbeat | `AiAnalysisJob.startedAt` + `AiRuntimeJob.startedAt` | - |
| `finishedAt` | `finishedAt` | DATETIME(3) | ✅ | - | ❌ | Worker/Runtime → 终态 | `AiAnalysisJob.completedAt` + `AiRuntimeJob.finishedAt` | - |
| `queuedAt` | `queuedAt` | DATETIME(3) | ✅ | - | ❌ | Service → create | `AiAnalysisJob.queuedAt` | - |
| `cancelRequestedAt` | `cancelRequestedAt` | DATETIME(3) | ✅ | - | ❌ | User/Admin cancel | `AiRuntimeJob.cancelRequestedAt` | - |
| `attemptCount` | `attemptCount` | INT | ❌ | `0` | ❌ | Worker → 重试时递增 | `AiRuntimeJob.retryCount`(重命名) | - |
| `maxAttempts` | `maxAttempts` | INT | ❌ | `3` | ❌ | JobType config | `AiRuntimeJob.maxRetryCount`(重命名) | - |
| `timeoutMs` | `timeoutMs` | INT | ❌ | `120000` | ❌ | JobType config | `AiRuntimeJob.timeoutSeconds × 1000`(单位变更) | - |
##### 输入输出M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 | 未来删除阶段 |
|------------|---------|------|----------|---------|-------|---------|-----------|------------|
| `inputRef` | `inputRef` | VARCHAR(255) | ✅ | - | ❌ | Service → create | 新增 | - |
| `inputSchemaVersion` | `inputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | Service → create | 新增 | - |
| `snapshotId` | `snapshotId` | VARCHAR(255) | ✅ | - | ❌ | SnapshotBuilderService | `AiRuntimeJob.snapshotId` | - |
| `promptKey` | `promptKey` | VARCHAR(128) | ✅ | - | ❌ | Service → create | 新增(`AiUsageLog.promptKey` 对应) | - |
| `promptVersion` | `promptVersion` | VARCHAR(100) | ✅ | - | ❌ | Service → create | `AiRuntimeJob.promptVersion` | - |
| `outputSchemaVersion` | `outputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | Service → create | `AiRuntimeJob.outputSchemaVersion` | - |
##### 凭据与模型M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 |
|------------|---------|------|----------|---------|-------|---------|-----------|
| `credentialMode` | `credentialMode` | VARCHAR(32) | ❌ | `'platform_key'` | ❌ | Settings → Service | `AiRuntimeJob.apiKeyMode`(重命名) |
| `credentialId` | `credentialId` | VARCHAR(255) | ✅ | - | ❌ | Settings → Service | `AiRuntimeJob.credentialId` |
| `modelTier` | `modelTier` | VARCHAR(32) | ❌ | `'primary'` | ❌ | ModelRoute config | 新增 |
| `modelProvider` | `modelProvider` | VARCHAR(32) | ❌ | `'deepseek'` | ❌ | ModelRoute config | `AiRuntimeJob.modelProvider` |
| `modelName` | `modelName` | VARCHAR(64) | ❌ | `'deepseek-chat'` | ❌ | ModelRoute config | `AiRuntimeJob.modelName` |
##### 错误与输出M-AI-02-04
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | Index | 写入来源 | 旧字段映射 |
|------------|---------|------|----------|---------|-------|---------|-----------|
| `errorCode` | `errorCode` | VARCHAR(100) | ✅ | - | ❌ | Worker → fail | `AiRuntimeJob.errorCode` |
| `publicErrorMessage` | `publicErrorMessage` | VARCHAR(500) | ✅ | - | ❌ | Worker → fail脱敏后 | 新增 |
| `internalErrorMessage` | `internalErrorMessage` | TEXT | ✅ | - | ❌ | Worker → fail完整堆栈 | `AiAnalysisJob.errorMessage` + `AiRuntimeJob.errorMessage` |
| `outputHash` | `outputHash` | VARCHAR(255) | ✅ | - | ❌ | Worker → success | `AiRuntimeResult.outputHash` |
| `validatedOutput` | `validatedOutput` | JSON | ✅ | - | ❌ | Worker → success脱敏后缓存 | `AiRuntimeResult.validatedOutput` |
##### 向后兼容字段
| Prisma 字段 | 物理列名 | 类型 | Nullable | Default | 当前 NULL 率 | 计划 |
|------------|---------|------|----------|---------|-------------|------|
| `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | 0% | 🔴 **过渡期保留**Shadow Write 必需)。旧代码 `AiAnalysisRepository.updateJobStatus()` 直接读写。M-AI-05 移除 |
| `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | 100% (9/9) | 保留但废弃(旧系统 AM-AI-05 删除 |
| `answerId` | `answerId` | VARCHAR(191) | ✅ | - | 78% (7/9) | 保留但废弃(旧系统 AM-AI-05 删除 |
### 3. 统一生命周期状态
新 lifecycleStatus 枚举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 回填历史 NULLM-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 | 说明 |
|------|----------|----------|-------------|------|
| 旧 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