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 草图
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):
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 实现):
// 旧 → 新(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(终态)
};
- 新代码写
lifecycleStatus 时,若 shadowMap[lifecycleStatus] 非 null,同步写 status
- 旧代码写
status 时,同步写 lifecycleStatus = reverseShadowMap[status]
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 草图
-- 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)
验收