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>
419 lines
27 KiB
Markdown
419 lines
27 KiB
Markdown
# 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<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)
|