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>
This commit is contained in:
parent
789d6ec15c
commit
800ce4a2b2
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,6 +41,9 @@ prisma/seed.d.ts
|
||||
*-error-*.png
|
||||
*-run-*.png
|
||||
|
||||
# Audit scripts (ephemeral, may contain credentials in env vars)
|
||||
scripts/
|
||||
|
||||
# Misc
|
||||
.cache/
|
||||
tmp/
|
||||
|
||||
418
docs/architecture/adr-002-ai-job-database-expand.md
Normal file
418
docs/architecture/adr-002-ai-job-database-expand.md
Normal file
@ -0,0 +1,418 @@
|
||||
# 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)
|
||||
652
docs/architecture/m-ai-02-current-schema-audit.md
Normal file
652
docs/architecture/m-ai-02-current-schema-audit.md
Normal file
@ -0,0 +1,652 @@
|
||||
# M-AI-02-01:现有 AI Job Schema 审计
|
||||
|
||||
> 审计日期:2026-06-20
|
||||
> 审计人:wangdl(半自动化审计:代码扫描 + 生产 MySQL 只读查询 via SSH 隧道)
|
||||
|
||||
---
|
||||
|
||||
## 1. Schema 逐字段审计
|
||||
|
||||
### 1.1 AiAnalysisJob(旧系统 A)
|
||||
|
||||
物理表名:`AiAnalysisJob` | 创建于:`prisma/migrations/20250516000000_init/migration.sql:300-319`
|
||||
|
||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiAnalysisJob_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
||||
| 3 | `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | `AiAnalysisJob_sessionId_idx` | ❌ | - | - | - |
|
||||
| 4 | `answerId` | `answerId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 5 | `jobType` | `jobType` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 6 | `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | `AiAnalysisJob_status_idx` | ❌ | - | - | - |
|
||||
| 7 | `progress` | `progress` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 8 | `errorMessage` | `errorMessage` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 9 | `queuedAt` | `queuedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 10 | `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 11 | `completedAt` | `completedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 12 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
||||
| 13 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
||||
|
||||
**Relation**:`results` → `AiAnalysisResult[]`(一对多)
|
||||
|
||||
---
|
||||
|
||||
### 1.2 AiAnalysisResult(旧系统 A 输出)
|
||||
|
||||
物理表名:`AiAnalysisResult` | 创建于:`prisma/migrations/20250516000000_init/migration.sql:322-342`
|
||||
|
||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiAnalysisResult_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
||||
| 3 | `jobId` | `jobId` | VARCHAR(191) | ❌ | - | `AiAnalysisResult_jobId_idx` | ❌ | `AiAnalysisJob.id` | RESTRICT | CASCADE |
|
||||
| 4 | `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | `AiAnalysisResult_sessionId_idx` | ❌ | - | - | - |
|
||||
| 5 | `answerId` | `answerId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 6 | `summary` | `summary` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 7 | `masteryScore` | `masteryScore` | INT | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 8 | `strengths` | `strengths` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 9 | `weaknesses` | `weaknesses` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 10 | `suggestions` | `suggestions` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 11 | `nextActions` | `nextActions` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 12 | `rawResult` | `rawResult` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 13 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
||||
| 14 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
||||
|
||||
**Relation**:`job` → `AiAnalysisJob`(多对一)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 AiRuntimeJob(新系统 B)
|
||||
|
||||
物理表名:`AiRuntimeJob` | 无独立 migration SQL,FK 由 Prisma schema push 创建,生产已确认存在
|
||||
|
||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiRuntimeJob_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
||||
| 3 | `jobType` | `jobType` | VARCHAR(64) | ❌ | - | `AiRuntimeJob_jobType_idx` | ❌ | - | - | - |
|
||||
| 4 | `targetType` | `targetType` | VARCHAR(32) | ❌ | - | 复合 `AiRuntimeJob_targetType_targetId_idx` | ❌ | - | - | - |
|
||||
| 5 | `targetId` | `targetId` | VARCHAR(255) | ❌ | - | 同上 | ❌ | - | - | - |
|
||||
| 6 | `snapshotId` | `snapshotId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 7 | `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | `AiRuntimeJob_status_idx` | ❌ | - | - | - |
|
||||
| 8 | `priority` | `priority` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 9 | `idempotencyKey` | `idempotencyKey` | VARCHAR(255) | ✅ | - | ❌ | ✅ | - | - | - |
|
||||
| 10 | `apiKeyMode` | `apiKeyMode` | VARCHAR(32) | ❌ | `'platform_key'` | ❌ | ❌ | - | - | - |
|
||||
| 11 | `credentialId` | `credentialId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 12 | `modelProvider` | `modelProvider` | VARCHAR(32) | ❌ | `'deepseek'` | ❌ | ❌ | - | - | - |
|
||||
| 13 | `modelName` | `modelName` | VARCHAR(64) | ❌ | `'deepseek-chat'` | ❌ | ❌ | - | - | - |
|
||||
| 14 | `promptVersion` | `promptVersion` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 15 | `outputSchemaVersion` | `outputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 16 | `attemptNo` | `attemptNo` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 17 | `retriedFromJobId` | `retriedFromJobId` | VARCHAR(255) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 18 | `lockedBy` | `lockedBy` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 19 | `lockedAt` | `lockedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 20 | `lockUntil` | `lockUntil` | DATETIME(3) | ✅ | - | `AiRuntimeJob_lockUntil_idx` | ❌ | - | - | - |
|
||||
| 21 | `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 22 | `finishedAt` | `finishedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 23 | `retryCount` | `retryCount` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 24 | `maxRetryCount` | `maxRetryCount` | INT | ❌ | `3` | ❌ | ❌ | - | - | - |
|
||||
| 25 | `timeoutSeconds` | `timeoutSeconds` | INT | ❌ | `120` | ❌ | ❌ | - | - | - |
|
||||
| 26 | `errorCode` | `errorCode` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 27 | `errorMessage` | `errorMessage` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 28 | `cancelRequestedAt` | `cancelRequestedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 29 | `cancelledAt` | `cancelledAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 30 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
||||
| 31 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
||||
|
||||
**Relation**:`result` → `AiRuntimeResult?`(一对一)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 AiRuntimeResult(新系统 B 输出)
|
||||
|
||||
物理表名:`AiRuntimeResult` | 无独立 migration SQL,FK 由 Prisma schema push 创建,生产已确认存在
|
||||
|
||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
||||
| 2 | `jobId` | `jobId` | VARCHAR(191) | ❌ | - | `AiRuntimeResult_jobId_idx` | ✅ | `AiRuntimeJob.id` | RESTRICT | CASCADE |
|
||||
| 3 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiRuntimeResult_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
||||
| 4 | `runtimeInstanceId` | `runtimeInstanceId` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 5 | `status` | `status` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 6 | `attemptNo` | `attemptNo` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 7 | `resultIdempotencyKey` | `resultIdempotencyKey` | VARCHAR(255) | ✅ | - | `AiRuntimeResult_resultIdempotencyKey_idx` | ❌ | - | - | - |
|
||||
| 8 | `outputHash` | `outputHash` | VARCHAR(255) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 9 | `rawOutput` | `rawOutput` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 10 | `validatedOutput` | `validatedOutput` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 11 | `schemaVersion` | `schemaVersion` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 12 | `validationErrors` | `validationErrors` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 13 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
||||
| 14 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
### 1.5 AiUsageLog(共享 AI 用量日志)
|
||||
|
||||
物理表名:`AiUsageLog` | 创建于:`prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql:9-30`;FK 同文件 line 48
|
||||
|
||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiUsageLog_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
||||
| 3 | `feature` | `feature` | VARCHAR(64) | ❌ | - | `AiUsageLog_feature_idx` | ❌ | - | - | - |
|
||||
| 4 | `provider` | `provider` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 5 | `model` | `model` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 6 | `tier` | `tier` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 7 | `promptKey` | `promptKey` | VARCHAR(128) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 8 | `promptVersion` | `promptVersion` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
||||
| 9 | `inputTokens` | `inputTokens` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 10 | `outputTokens` | `outputTokens` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 11 | `estimatedCost` | `estimatedCost` | DOUBLE | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 12 | `latencyMs` | `latencyMs` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
||||
| 13 | `success` | `success` | BOOLEAN | ❌ | `true` | ❌ | ❌ | - | - | - |
|
||||
| 14 | `errorMessage` | `errorMessage` | VARCHAR(500) | ✅ | - | ❌ | ❌ | - | - | - |
|
||||
| 15 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | `AiUsageLog_createdAt_idx` | ❌ | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
### 1.6 FK 约束来源:Prisma 隐式默认 vs 显式声明
|
||||
|
||||
全部 7 个 `@relation` 在 Prisma schema 中均**未显式声明** `onDelete` / `onUpdate`:
|
||||
|
||||
```prisma
|
||||
// 典型模式(无显式 onDelete/onUpdate)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
job AiAnalysisJob @relation(fields: [jobId], references: [id])
|
||||
```
|
||||
|
||||
生产数据库 `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS` 确认全部 7 个 FK 均为 `RESTRICT / CASCADE`,这与 Prisma 默认行为一致:
|
||||
- Prisma 默认 `onDelete: Restrict`
|
||||
- Prisma 默认 `onUpdate: Cascade`
|
||||
|
||||
**对 M-AI-02 的影响**:
|
||||
|
||||
1. **M-AI-02-03/04 新增 FK 时应显式声明**:避免依赖 Prisma 默认值(不同版本/方言可能不同),在 schema 中显式写出 `onDelete: Restrict, onUpdate: Cascade` 以保持全表一致
|
||||
2. **AiUsageLog.jobId FK(M-AI-02-08)**:新增 FK 应显式声明,**但需评估 `onDelete: SetNull` vs `Restrict`**——删除 Job 时 UsageLog 应保留以维持计费审计完整性
|
||||
3. **OutboxEvent FK(M-AI-02-07)**:新增 Outbox 表的 FK 应显式声明,通常为 `onDelete: Cascade`(Job 删除时级联删 Outbox)
|
||||
|
||||
---
|
||||
|
||||
## 2. 状态值枚举
|
||||
|
||||
### 2.1 AiAnalysisJob.status
|
||||
|
||||
无 enum 定义,使用任意 string。实际使用值:
|
||||
|
||||
| 值 | 使用位置 | 说明 |
|
||||
|----|---------|------|
|
||||
| `pending` | `ai-analysis.repository.ts:15` | 创建时默认 |
|
||||
| `processing` | `ai-analysis.repository.ts:23`, `ai-analysis.worker.ts:48` | Worker 开始处理 |
|
||||
| `completed` | `ai-analysis.repository.ts:24`, `ai-analysis.worker.ts:68` | Worker 处理成功 |
|
||||
| `failed` | `ai-analysis.repository.ts:24`, `ai-analysis.worker.ts:102` | Worker 处理失败 |
|
||||
| `queued` | `ai-analysis.service.ts:30,51` | API 返回给客户端的虚拟状态(不写入 DB) |
|
||||
|
||||
### 2.2 AiRuntimeJob.status
|
||||
|
||||
无 enum 定义,使用任意 string。实际使用值:
|
||||
|
||||
| 值 | 使用位置 | 说明 |
|
||||
|----|---------|------|
|
||||
| `pending` | `user-ai.service.ts:289,315,331`, `runtime-internal.service.ts:27,92,594` | 创建/重试回退 |
|
||||
| `locked` | `runtime-internal.service.ts:99,123` | Runtime 获取锁 |
|
||||
| `running` | `runtime-internal.service.ts:129,131`, `job-reaper.service.ts:40,54`, `platform-budget.service.ts:55` | 执行中 |
|
||||
| `succeeded` | `runtime-internal.service.ts:268` | 执行成功 |
|
||||
| `failed` | `runtime-internal.service.ts:605`, `job-reaper.service.ts:98`, `submitFailure()` | 执行失败/超时终止 |
|
||||
| `cancelled` | `user-ai.service.ts:353`, `runtime-internal.service.ts:583` | 用户取消 |
|
||||
| `expired` | `docs/ai-job-state-machine.md:23` | 文档定义,代码中未显式赋此值 |
|
||||
|
||||
### 2.3 AiRuntimeResult.status
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `succeeded` | 最终成功(代码中 `runtime-internal.service.ts:249` 检查) |
|
||||
| 其他值未明确定义 | `submitResult()` 接受任意 `dto.status` |
|
||||
|
||||
---
|
||||
|
||||
## 3. JobType 枚举
|
||||
|
||||
### 3.1 AiAnalysisJob.jobType(旧系统 A)
|
||||
|
||||
| 值 | 使用位置 | 说明 |
|
||||
|----|---------|------|
|
||||
| `active-recall` | `ai-analysis.service.ts:19` | 主动回忆分析 |
|
||||
| `feynman-evaluation` | `ai-analysis.service.ts:40` | 费曼评估分析 |
|
||||
|
||||
### 3.2 AiRuntimeJob.jobType(新系统 B)
|
||||
|
||||
| 值 | 使用位置 | 说明 |
|
||||
|----|---------|------|
|
||||
| `learning_state_analysis` | `user-ai.dto.ts:64` | 学习状态分析 |
|
||||
| `weak_point_analysis` | `user-ai.dto.ts:65` | 薄弱点分析 |
|
||||
| `next_action_planning` | `user-ai.dto.ts:66` | 下一步建议 |
|
||||
| `quiz_generation` | `user-ai.dto.ts:67` | 题目生成 |
|
||||
| `flashcard_generation` | `user-ai.dto.ts:68` | 卡片生成 |
|
||||
|
||||
**关键发现**:两套系统的 jobType 互不重叠,不存在同名冲突。
|
||||
|
||||
---
|
||||
|
||||
## 4. 生产数据审计
|
||||
|
||||
> 查询时间:2026-06-20 03:14 UTC
|
||||
> 数据库:zhixi_prod @ 120.53.227.155:3306(蜂驰云 8C 生产服务器,SSH 隧道连接)
|
||||
> MySQL 版本:8.0.46
|
||||
> 查询脚本:`scripts/query-audit-data.ts`
|
||||
|
||||
### 4.1 AiAnalysisJob
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| **总记录数** | 9 |
|
||||
| 表大小 | 0.06 MB |
|
||||
| 最早记录 | 2026-06-19T14:34:05.815Z |
|
||||
| 最新记录 | 2026-06-19T14:48:10.489Z |
|
||||
|
||||
#### 状态分布
|
||||
|
||||
| 状态 | 数量 | 占比 |
|
||||
|------|------|------|
|
||||
| `completed` | 5 | 56% |
|
||||
| `failed` | 4 | 44% |
|
||||
|
||||
#### jobType 分布
|
||||
|
||||
| jobType | 数量 |
|
||||
|---------|------|
|
||||
| `feynman-evaluation` | 7 |
|
||||
| `active-recall` | 2 |
|
||||
|
||||
#### NULL 字段分布
|
||||
|
||||
| 字段 | NULL 数 | NULL 率 |
|
||||
|------|---------|---------|
|
||||
| `sessionId` | 9 | 100% |
|
||||
| `answerId` | 7 | 78% |
|
||||
| `errorMessage` | 5 | 56%(4 failed jobs 均有 errorMessage) |
|
||||
| `queuedAt` | 0 | 0% |
|
||||
| `startedAt` | 0 | 0% |
|
||||
| `completedAt` | 0 | 0% — 正确。`AiAnalysisRepository.updateJobStatus()` 对 `completed`/`failed` 均显式设置 `completedAt = new Date()`(`ai-analysis.repository.ts:23-24`)。当前全部 9 行均为终态(5 `completed` + 4 `failed`),无 `pending`/`processing` 滞留,故 0 NULL 符合预期。`DateTime?` 类型不生成 `DEFAULT CURRENT_TIMESTAMP`,Prisma `@updatedAt` 也不影响此列。 |
|
||||
|
||||
#### 最大字段长度
|
||||
|
||||
| 字段 | 最大长度 |
|
||||
|------|----------|
|
||||
| `errorMessage` | 797 chars |
|
||||
|
||||
### 4.2 AiAnalysisResult
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| **总记录数** | 5 |
|
||||
| 表大小 | 0.06 MB |
|
||||
| 孤儿 FK(jobId 不存在) | 0 |
|
||||
| 重复 (userId, jobId) | 0 |
|
||||
|
||||
#### 最大 JSON 字段长度
|
||||
|
||||
| 字段 | 最大长度 |
|
||||
|------|----------|
|
||||
| `summary` | 162 chars |
|
||||
| `strengths` | 61 chars |
|
||||
| `weaknesses` | 184 chars |
|
||||
| `suggestions` | 367 chars |
|
||||
| `nextActions` | 185 chars |
|
||||
| `rawResult` | 1,048 chars |
|
||||
|
||||
#### 父 Job 状态分布
|
||||
|
||||
| 父 Job 状态 | Result 数量 |
|
||||
|-----------|------------|
|
||||
| `completed` | 5 |
|
||||
|
||||
> 结论:所有 5 个 Result 均属于 completed Job,无 failed Job 产生 Result(符合代码逻辑)。
|
||||
|
||||
#### NULL 字段
|
||||
|
||||
| 字段 | NULL 数 |
|
||||
|------|---------|
|
||||
| `sessionId` | 5 (100%) |
|
||||
| `answerId` | 5 (100%) |
|
||||
| `summary` / `masteryScore` / `strengths` / etc. | 0 |
|
||||
|
||||
### 4.3 AiRuntimeJob
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| **总记录数** | **0** |
|
||||
| 表大小 | 0.11 MB(空表,空间为 initial extent 分配) |
|
||||
|
||||
> **确认**:ADR "唯一创建 AiRuntimeJob 的入口无人调用" 的断言正确。该表在生产中为零记录。
|
||||
|
||||
### 4.4 AiRuntimeResult
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| **总记录数** | **0** |
|
||||
| 表大小 | 0.08 MB(空表) |
|
||||
|
||||
### 4.5 AiUsageLog
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| **总记录数** | 110 |
|
||||
| 表大小 | 0.11 MB |
|
||||
| 最早记录 | 2026-05-28T13:02:12.261Z |
|
||||
| 最新记录 | 2026-06-19T14:52:40.199Z |
|
||||
| success=true | 30 (27.3%) |
|
||||
| success=false | 80 (72.7%) |
|
||||
| errorMessage NULL | 30(仅 success=true 的日志无错误消息) |
|
||||
|
||||
#### feature 分布
|
||||
|
||||
| feature | 总数 | 成功 | 失败 | 成功率 |
|
||||
|---------|------|------|------|--------|
|
||||
| `learning-trend` | 70 | 11 | 59 | 15.7% |
|
||||
| `knowledge-import` | 15 | 1 | 14 | 6.7% |
|
||||
| `rag-chat` | 13 | 12 | 1 | 92.3% |
|
||||
| `active-recall-analysis` | 7 | 1 | 6 | 14.3% |
|
||||
| `feynman-evaluation` | 4 | 4 | 0 | 100% |
|
||||
| `review-card-generation` | 1 | 1 | 0 | 100% |
|
||||
|
||||
#### provider 分布
|
||||
|
||||
| provider | 数量 |
|
||||
|----------|------|
|
||||
| `minimax` | 59 |
|
||||
| `deepseek` | 51 |
|
||||
|
||||
#### model 分布
|
||||
|
||||
| model | 数量 |
|
||||
|-------|------|
|
||||
| `minimax-m2.7` | 59 | **全部来自已到期 API Key(2026-06-06 到期)** |
|
||||
| `deepseek-v4-pro` | 35 | 当前主力模型 |
|
||||
| `deepseek-v4-flash` | 16 | 轻量兜底 |
|
||||
|
||||
#### AiUsageLog 成功率根因分析
|
||||
|
||||
107/110 的失败率(72.7%)由以下因素叠加导致:
|
||||
|
||||
1. **MiniMax M2.7 API Key 已到期**(2026-06-06 到期,见 devops `轻量云服务器凭据.md`)
|
||||
- `minimax-m2.7` 共 59 次调用,失败 59 次(100% 失败率)
|
||||
- 影响 feature:`learning-trend`(59 次全部 minimax)、`knowledge-import`(14/15 次失败含大量 minimax)
|
||||
2. **`learning-trend`**(70 次,成功率 16%)是后台定时任务 → 高调用量 × minimax 到期 = 大量失败
|
||||
3. **`rag-chat`**(13 次,成功率 92%)使用 `deepseek-v4-pro` → 几乎全部成功
|
||||
4. **`feynman-evaluation`**(4 次,成功率 100%)使用 `deepseek-v4-pro` → 全部成功
|
||||
|
||||
**结论**:排除 Minimax 到期影响后,DeepSeek 成功率接近 100%。2026-06-06 后所有调用已切换至 DeepSeek V4 Pro/V4 Flash(蜂驰云生产凭据确认),M-AI-02 无需为 Minimax 保留兼容。
|
||||
|
||||
#### 孤儿外键
|
||||
|
||||
| 检测 | SQL | 结果 |
|
||||
|------|-----|------|
|
||||
| `AiAnalysisResult.jobId` → `AiAnalysisJob.id` | `SELECT COUNT(*) FROM AiAnalysisResult r LEFT JOIN AiAnalysisJob j ON r.jobId=j.id WHERE j.id IS NULL` | 0 |
|
||||
| `AiAnalysisResult.userId` → `User.id` | 同上模式 | 0 |
|
||||
| `AiAnalysisJob.userId` → `User.id` | 同上模式 | 0 |
|
||||
| `AiUsageLog.userId` → `User.id` | 同上模式(有 FK RESTRICT,不可能存在) | 0 |
|
||||
|
||||
#### 重复业务记录
|
||||
|
||||
**定义**:重复 = 同一用户对同一 jobType 在 **1 秒内** 创建 ≥2 条记录(并发创建)。
|
||||
|
||||
| 检测模式 | SQL | 结果 |
|
||||
|---------|-----|------|
|
||||
| `(userId, jobType)` 1秒并发 | `SELECT a.id,b.id FROM AiAnalysisJob a JOIN AiAnalysisJob b ON a.userId=b.userId AND a.jobType=b.jobType WHERE a.id<b.id AND ABS(TIMESTAMPDIFF(SECOND,a.createdAt,b.createdAt))<=1` | **1 对** |
|
||||
| `(userId, sessionId, jobType)` 并发 | `GROUP BY userId,sessionId,jobType HAVING cnt>1 WHERE sessionId IS NOT NULL` | 0(sessionId 100% NULL 导致) |
|
||||
| `(userId, jobId)` 在 AiAnalysisResult | `GROUP BY userId,jobId HAVING cnt>1` | 0 |
|
||||
| AiUsageLog 同秒双写 | `GROUP BY userId,feature,provider,model,createdAt HAVING cnt>1` | 0 |
|
||||
|
||||
**发现的并发重复详情**:
|
||||
|
||||
```
|
||||
id1=cmql14u46007nwfzktt0ttsn0 id2=cmql14uey007rwfzkl9uf0ssz
|
||||
jobType=feynman-evaluation status=completed/completed
|
||||
createdAt=2026-06-19T14:34:05Z / 14:34:06Z diff=0s
|
||||
```
|
||||
|
||||
**评估**:两个 Job 均在 1 秒内创建,jobType 相同(`feynman-evaluation`),最终状态均为 `completed`。这是 `AiAnalysisService.evaluateFeynman()` 的并发调用导致。**AiAnalysisJob 表无 (userId, sessionId, jobType) 唯一约束**,无法在数据库层防御并发重复。M-AI-02-03 引入 `idempotencyKey` 后可通过业务层幂等解决。
|
||||
|
||||
#### JSON/TEXT 字段最大容量
|
||||
|
||||
**检测方法**:`SELECT MAX(LENGTH(column)) FROM table`
|
||||
|
||||
| 表 | 字段 | 类型 | 当前最大 | 检测 SQL |
|
||||
|----|------|------|---------|----------|
|
||||
| `AiAnalysisJob` | `errorMessage` | TEXT | **797 chars** | `SELECT MAX(LENGTH(errorMessage)) FROM AiAnalysisJob` |
|
||||
| `AiAnalysisResult` | `rawResult` | JSON | **1,048 chars** | `SELECT MAX(LENGTH(rawResult)) FROM AiAnalysisResult` |
|
||||
| `AiAnalysisResult` | `strengths` | JSON | 61 | 同上 |
|
||||
| `AiAnalysisResult` | `weaknesses` | JSON | 184 | 同上 |
|
||||
| `AiAnalysisResult` | `suggestions` | JSON | **367 chars** | 同上 |
|
||||
| `AiAnalysisResult` | `nextActions` | JSON | 185 | 同上 |
|
||||
| `AiAnalysisResult` | `summary` | TEXT | 162 | 同上 |
|
||||
| `AiRuntimeJob` | `errorMessage` | TEXT | 0(零记录) | `SELECT MAX(LENGTH(errorMessage)) FROM AiRuntimeJob` |
|
||||
| `AiRuntimeResult` | `rawOutput` | JSON | NULL(零记录) | `SELECT MAX(LENGTH(rawOutput)) FROM AiRuntimeResult` |
|
||||
| `AiRuntimeResult` | `validatedOutput` | JSON | NULL(零记录) | 同上 |
|
||||
|
||||
**M-AI-02-04 容量建议**:
|
||||
- 当前 JSON/TEXT 字段均在 1KB 级别,远低于 MEDIUMTEXT(16MB)上限
|
||||
- `rawResult` 最大 1KB → M-AI-02-04 新增的 `AiJobSnapshot` 需要容纳完整的 snapshot JSON(约 5-10KB),建议 `MEDIUMTEXT` 或 `JSON`(MySQL JSON 列上限 1GB,但索引友好)
|
||||
- `AiAnalysisJob.errorMessage` 最大 797 chars → 新建 `AiJob.errorMessage` 保持 `TEXT`(64KB)即可,无需 `MEDIUMTEXT`
|
||||
|
||||
---
|
||||
|
||||
## 5. 调用方完整清单
|
||||
|
||||
### 5.1 `prisma.aiAnalysisJob` 直接访问
|
||||
|
||||
| 文件 | 行号 | 操作 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 9 | `create` | 创建 Job |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 26 | `update` | 更新 Job 状态 |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 30 | `findUnique` | 查询 Job(include results) |
|
||||
|
||||
### 5.2 `prisma.aiAnalysisResult` 直接访问
|
||||
|
||||
| 文件 | 行号 | 操作 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 37 | `create` | 创建分析结果 |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 53 | `findUnique` | 查询分析结果 |
|
||||
| `src/modules/learning-session/admin-learning.service.ts` | 229 | `findMany` | Admin 分页查询 |
|
||||
| `src/modules/learning-session/admin-learning.service.ts` | 233 | `count` | Admin 总数 |
|
||||
|
||||
### 5.3 `prisma.aiRuntimeJob` 直接访问
|
||||
|
||||
| 文件 | 行号 | 操作 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `src/modules/ai-runtime/user-ai.service.ts` | 226 | `findUnique` | 幂等检查 |
|
||||
| `src/modules/ai-runtime/user-ai.service.ts` | 282 | `create` | 创建 Job |
|
||||
| `src/modules/ai-runtime/user-ai.service.ts` | 344 | `findFirst` | 取消查询 |
|
||||
| `src/modules/ai-runtime/user-ai.service.ts` | 351,359 | `update` | 取消更新 |
|
||||
| `src/modules/ai-runtime/user-ai.service.ts` | 429 | `findFirst` | 状态查询 |
|
||||
| `src/modules/ai-runtime/user-ai.service.ts` | 448 | `findMany` | 列表查询 |
|
||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 36 | `findMany` | Poll 查询 |
|
||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 89 | `updateMany` | Lock |
|
||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 123,129 | `updateMany` | Heartbeat |
|
||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 139,154,202,225,576,661 | `findUnique` | Snapshot/Result/Fail 查询 |
|
||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 192,268,583,594,605 | `update` | Snapshot/Result/Fail 更新 |
|
||||
| `src/modules/ai-runtime/job-reaper.service.ts` | 28 | `updateMany` | 清扫过期锁 |
|
||||
| `src/modules/ai-runtime/job-reaper.service.ts` | 39,69 | `findMany` | 查询 stuck/expired |
|
||||
| `src/modules/ai-runtime/job-reaper.service.ts` | 53,82,96 | `updateMany` | 清扫失败/过期 |
|
||||
| `src/modules/ai-runtime/platform-budget.service.ts` | 53 | `count` | 平台预算检查 |
|
||||
|
||||
### 5.4 `prisma.aiUsageLog` 直接访问
|
||||
|
||||
| 文件 | 行号 | 操作 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `src/modules/ai/usage/ai-usage-log.service.ts` | 28 | `create` | 写日志 |
|
||||
| `src/modules/admin-costs/cost-aggregation.service.ts` | 31 | `findMany` | 成本聚合 |
|
||||
| `src/modules/admin-metrics/admin-metrics.controller.ts` | 56 | `findMany` | AI 耗时统计 |
|
||||
| `src/modules/admin-dashboard/admin-dashboard.service.ts` | 46 | `count` | 今日 AI 调用数 |
|
||||
| `src/modules/admin-costs/admin-costs.service.ts` | 85 | `groupBy` | 成本统计 |
|
||||
| `src/modules/learning-session/admin-learning.service.ts` | 247,251 | `findMany`/`count` | Admin 查询 |
|
||||
|
||||
### 5.5 间接调用方(通过 Repository/Service)
|
||||
|
||||
| 调用方 | 文件 | 说明 |
|
||||
|--------|------|------|
|
||||
| `AiAnalysisWorker.process()` | `src/workers/ai-analysis.worker.ts:48-102` | 通过 `AiAnalysisRepository` 更新状态和结果 |
|
||||
| `AiAnalysisService.analyze()` | `src/modules/ai-analysis/ai-analysis.service.ts:12-31` | 创建 Job 并入队 |
|
||||
| `AiAnalysisService.evaluateFeynman()` | `src/modules/ai-analysis/ai-analysis.service.ts:33-52` | 创建 Job 并入队 |
|
||||
| `AiAnalysisService.getJobStatus()` | `src/modules/ai-analysis/ai-analysis.service.ts:54-67` | 查询状态(序列化给客户端) |
|
||||
| `AdminLearningService.getAnalysis()` | `src/modules/learning-session/admin-learning.service.ts:223-236` | Admin 分页查询 |
|
||||
| `AdminLearningService.getAiUsage()` | `src/modules/learning-session/admin-learning.service.ts:240-254` | Admin 分页查询 |
|
||||
| `AiUsageLogService.log()` | `src/modules/ai/usage/ai-usage-log.service.ts:26-32` | 写用量日志 |
|
||||
| `CostAggregationService.aggregateToday()` | `src/modules/admin-costs/cost-aggregation.service.ts:27-59` | 成本聚合 |
|
||||
| `AdminMetricsController.aiMetrics()` | `src/modules/admin-metrics/admin-metrics.controller.ts:54-89` | AI 耗时统计 |
|
||||
| `AdminDashboardService.getStats()` | `src/modules/admin-dashboard/admin-dashboard.service.ts:46` | 仪表盘统计 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试文件引用
|
||||
|
||||
| 文件 | 引用方式 | 影响 |
|
||||
|------|---------|------|
|
||||
| `test/helpers/integration-harness.ts:228` | 硬编码表名 `'AiAnalysisResult'`, `'AiAnalysisJob'` | **重命名 Prisma model 后需同步** |
|
||||
| `src/modules/ai-runtime/user-ai.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
||||
| `src/modules/ai-runtime/job-reaper.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
||||
| `src/modules/ai-runtime/platform-budget.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
||||
| `src/modules/ai-runtime/internal/runtime-internal.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
||||
| `test/api-e2e.worker-int-spec.ts` | Raw SQL 8 处 + 字符串引用 8 处 = **16 处**引用物理表名 `AiAnalysisJob` / `AiAnalysisResult` | M-AI-02-09 重命名后需全部同步 | SQL: 91,101,107,197,210,218,252,255;字符串: 88,105,108,195,215,219,253,256 |
|
||||
| `test/worker-integration.worker-int-spec.ts` | Raw SQL 13 处 + 字符串引用 8 处 = **21 处**引用物理表名 | M-AI-02-09 重命名后需全部同步 | SQL: 113,120,127,158,170,212,245,259,270,282,299,313,316;字符串: 110,114,118,121,125,247,314,317 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 兼容风险分析
|
||||
|
||||
### 7.1 Model 名依赖
|
||||
|
||||
**风险等级:中等**
|
||||
|
||||
以下代码依赖 Prisma model 名 `AiAnalysisJob`、`AiAnalysisResult`:
|
||||
|
||||
| 依赖点 | 文件 | 行号 | 影响 |
|
||||
|--------|------|------|------|
|
||||
| `prisma.aiAnalysisJob` | `ai-analysis.repository.ts` | 9,26,30 | 重命名 model 会导致编译错误 |
|
||||
| `prisma.aiAnalysisResult` | `ai-analysis.repository.ts` | 37,53 | 同上 |
|
||||
| `prisma.aiAnalysisResult` | `admin-learning.service.ts` | 229,233 | 同上 |
|
||||
| 硬编码表名字符串 | `test/helpers/integration-harness.ts` | 228 | 清理表时使用物理表名 `'AiAnalysisJob'`、`'AiAnalysisResult'` |
|
||||
|
||||
**注意**:`prisma.aiRuntimeJob`、`prisma.aiUsageLog` 不会被 M-AI-02 重命名(当前范围仅统一 `AiAnalysisJob → AiJob`)。
|
||||
|
||||
### 7.2 状态 enum 依赖
|
||||
|
||||
**风险等级:低**
|
||||
|
||||
- 所有状态值均为 raw string,无 enum 定义
|
||||
- `AiAnalysisJob` 使用 `pending`/`processing`/`completed`/`failed`
|
||||
- `AiRuntimeJob` 使用 `pending`/`locked`/`running`/`succeeded`/`failed`/`cancelled`
|
||||
- 两套状态值语义不同,M-AI-02-04 需要统一
|
||||
- 当前代码中没有任何 `AiAnalysisJobStatus` type/enum 定义
|
||||
|
||||
### 7.3 API 直接序列化数据库状态
|
||||
|
||||
**风险等级:中等**
|
||||
|
||||
| API | 文件 | 行号 | 序列化字段 |
|
||||
|-----|------|------|----------|
|
||||
| `AiAnalysisService.getJobStatus()` | `ai-analysis.service.ts:54-67` | 直接透传 `job.status`、`job.jobType`、`job.queuedAt`、`job.startedAt`、`job.completedAt` | **字段名变更会影响 iOS** |
|
||||
| `UserAiService.getJob()` | `user-ai.service.ts:428-443` | 选择字段直接序列化 `jobType`、`status`、`errorCode`、`errorMessage` 等 | **字段名变更会影响 iOS** |
|
||||
| `UserAiService.listJobs()` | `user-ai.service.ts:445-459` | 同上 | 同上 |
|
||||
| `UserAiService.createAnalysisJob()` 返回值 | `user-ai.service.ts:340` | `{ jobId, status, createdAt }` | 不受影响(仅返回 ID+状态) |
|
||||
|
||||
### 7.4 Relation 重命名影响
|
||||
|
||||
**风险等级:低**
|
||||
|
||||
- `AiAnalysisJob.results` → `AiAnalysisResult[]`(系统 A)
|
||||
- `AiAnalysisResult.job` → `AiAnalysisJob`(系统 A)
|
||||
- `AiRuntimeJob.result` → `AiRuntimeResult?`(系统 B)
|
||||
- `AiRuntimeResult.job` → `AiRuntimeJob`(系统 B)
|
||||
- M-AI-02-09 将 `AiAnalysisJob` 重命名为 `AiJob` 时,`AiAnalysisResult.job` 的 relation 类型需要同时更新
|
||||
|
||||
### 7.5 Raw SQL / 手写表名
|
||||
|
||||
**风险等级:中高**
|
||||
|
||||
- 源文件(`src/`)中无 `$queryRaw`/`$executeRaw` 引用 AI 分析相关表
|
||||
- `worker.main.ts:24` 和 `system.controller.ts:35` 仅使用 `SELECT 1` 做健康检查
|
||||
- **重要发现**:E2E 测试文件使用 Raw SQL 直接查询物理表:
|
||||
- `test/api-e2e.worker-int-spec.ts`:8 处 Raw SQL + 8 处字符串引用(it/console.log)= **16 处**硬编码表名 `'AiAnalysisJob'`、`'AiAnalysisResult'`
|
||||
- `test/worker-integration.worker-int-spec.ts`:13 处 Raw SQL + 8 处字符串引用 = **21 处**同上
|
||||
- **合计 37 处引用**,其中 21 处为 Raw SQL(`SELECT ... FROM AiAnalysisJob`),16 处为字符串引用
|
||||
- **M-AI-02-09 重命名物理表后,这 37 处引用必须全部同步更新**
|
||||
|
||||
### 7.6 报表/Admin 直接查询物理表
|
||||
|
||||
**风险等级:中等**
|
||||
|
||||
所有 Admin 查询通过 Prisma Client,不会直接访问物理表。但以下聚合逻辑依赖现有字段:
|
||||
|
||||
| Admin 功能 | 文件 | 依赖字段 |
|
||||
|-----------|------|---------|
|
||||
| Dashboard 今日 AI 调用数 | `admin-dashboard.service.ts:46` | `AiUsageLog.createdAt` |
|
||||
| AI 耗时统计 | `admin-metrics.controller.ts:56-86` | `AiUsageLog.provider`、`model`、`latencyMs`、`success`、`estimatedCost` |
|
||||
| 成本聚合 | `cost-aggregation.service.ts:31-56` | `AiUsageLog.*` |
|
||||
| Admin 分析结果 | `admin-learning.service.ts:229-233` | `AiAnalysisResult.*` |
|
||||
|
||||
### 7.7 跨表数据关系
|
||||
|
||||
| 源表 | 目标表 | 关系 | 当前状态 | M-AI-02 影响 |
|
||||
|------|--------|------|----------|-------------|
|
||||
| `AiAnalysisJob` | `AiAnalysisResult` | 1:N FK | 生产运行中 | 需保留 |
|
||||
| `AiAnalysisJob` | `FocusItem` | 逻辑关联(Worker 进程内直接调用 `FocusItemsService.create()`) | 生产运行中 | 不直接受影响 |
|
||||
| `AiAnalysisJob` | `ReviewCard` | 逻辑关联(EventEmitter → Subscriber → AI Workflow → `ReviewCard` 表) | 生产运行中 | **M-AI-05 Outbox 替代时必须保持此链** |
|
||||
| `AiRuntimeJob` | `AiRuntimeResult` | 1:1 FK | 闲置(无生产者) | M-AI-03 规划使用 |
|
||||
| `AiRuntimeJob` | `LearningAnalysisSnapshot` | 逻辑关联(snapshotId) | 闲置 | M-AI-03 规划使用 |
|
||||
| `AiRuntimeJob` | `AiLearningAnalysis` | 逻辑关联(jobId) | 闲置 | M-AI-04 规划使用 |
|
||||
| `AiRuntimeJob` | `QuestionGenerationPlan` | 逻辑关联(jobId) | 闲置 | M-AI-06 规划使用 |
|
||||
| `AiRuntimeJob` | `FlashcardGenerationPlan` | 逻辑关联(jobId) | 闲置 | M-AI-06 规划使用 |
|
||||
| `AiUsageLog` | 无 FK 到 Job 表 | **无关联** | - | **M-AI-02-08 需新增** |
|
||||
|
||||
### `ai.analysis.completed` 事件订阅链完整追踪
|
||||
|
||||
**对 M-AI-05(Outbox 替代 EventEmitter)至关重要。**
|
||||
|
||||
```
|
||||
AiAnalysisWorker.process() // src/workers/ai-analysis.worker.ts:72
|
||||
└─ eventBus.publish(new AIAnalysisCompleted(...)) // 发布事件
|
||||
│
|
||||
└─ ReviewCardSubscriber.handleAIAnalysisCompleted() // src/modules/review/review-card.subscriber.ts:11
|
||||
│ @OnEvent('ai.analysis.completed')
|
||||
│ 订阅 payload: { userId, jobId, sessionId?, answerId?, type, score?, analysis? }
|
||||
│
|
||||
└─ ReviewService.generateCards(userId, input) // src/modules/review/review.service.ts:68
|
||||
│ 从 payload.analysis 中提取 weaknesses/strengths/summary
|
||||
│ 拼接为 prompt 文本 → CardGenerationWorkflow.execute()
|
||||
│
|
||||
└─ ReviewRepository.insertCard(data) // src/modules/review/review.repository.ts:38
|
||||
│ this.prisma.reviewCard.create({ data })
|
||||
│
|
||||
└─ 写入 ReviewCard 表 (prisma/schema.prisma:642-668)
|
||||
字段: userId, frontText, backText, difficulty, status,
|
||||
intervalDays, easeFactor, repetitionCount, lapseCount,
|
||||
scheduleState, nextReviewAt
|
||||
```
|
||||
|
||||
**关键发现**:
|
||||
|
||||
1. `ReviewCard` **无 `jobId` FK** — 无法溯源哪次 AI 分析生成了哪张卡片
|
||||
2. Subscriber 依赖进程内 EventEmitter — M-AI-05 改为 Outbox 后,此链必须保持
|
||||
3. `FocusItem` 不走事件订阅 — Worker 进程内直接调 `focusItems.create()`(`ai-analysis.worker.ts:88`),不存在可靠投递保证
|
||||
4. Subscriber 声明的 payload 接口(`review-card.subscriber.ts:12-20`)与实际 `AIAnalysisCompleted` event 类(`ai-analysis.worker.ts:13-16`)完全耦合 — 任何 event schema 变更需同步修改 subscriber
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键发现总结
|
||||
|
||||
1. **生产数据量极小**:AiAnalysisJob 仅 9 行,AiAnalysisResult 仅 5 行,AiUsageLog 110 行。所有表 < 0.12 MB。**Migration 锁表风险极低**(毫秒级 ALTER TABLE)。
|
||||
2. **AiRuntimeJob/AiRuntimeResult 零记录**:确认 ADR 断言正确——系统 B 无生产者活动。M-AI-02 Expand 无需回填历史 RuntimeJob 数据。
|
||||
3. **两套系统并存**:`AiAnalysisJob`(生产运行中,active-recall + feynman)和 `AiRuntimeJob`(闲置,5 种分析类型)是两套完全独立的表,无任何 FK/逻辑关联
|
||||
4. **AiUsageLog 与 Job 表无关联**:当前 `AiUsageLog` 无 `jobId` 字段,无法追踪单次 AI 调用归属。**M-AI-02-08 需要新增 `jobId` FK**
|
||||
5. **无 Outbox/事件溯源**:系统 A 通过 NestJS EventEmitter 发布事件(`ai.analysis.completed`),系统 B 在 API 进程内同步写 Notification——无持久化事件表
|
||||
6. **状态值非标准化**:无 enum 定义,两套状态语义不兼容(`processing` vs `locked`/`running`,`completed` vs `succeeded`)
|
||||
7. **AiRuntimeJob 字段远多于 AiAnalysisJob**:31 vs 13 字段,M-AI-02 Expand 后应保持一致
|
||||
8. **无 raw SQL 引用 AI 表**:AI 表仅通过 Prisma Client 访问
|
||||
9. **37 处测试文件硬编码表名**:`test/api-e2e.worker-int-spec.ts`(16) 和 `test/worker-integration.worker-int-spec.ts`(21) 中 Raw SQL 及字符串引用物理表名 `'AiAnalysisJob'`/`'AiAnalysisResult'`,M-AI-02-09 重命名后必须全部同步。另有 `test/helpers/integration-harness.ts:228` 的清理列表
|
||||
10. **sessionId/answerId 高 NULL 率**:AiAnalysisJob 中 sessionId 100% NULL,answerId 78% NULL——M-AI-02-04 可评估是否保留这两个字段
|
||||
11. **无孤儿 FK**:所有 FK 引用一致,数据完整性良好
|
||||
12. **全部 FK 来自 Prisma 默认值**:Prisma schema 中 7 个 `@relation` 均未显式声明 `onDelete`/`onUpdate`,生产实际值为 RESTRICT/CASCADE(Prisma 默认)。M-AI-02-03/04 新增 FK 应**显式声明**以避免跨版本/跨方言歧义(见 1.6 节)
|
||||
13. **ReviewCard 无 jobId 溯源**:`ai.analysis.completed` → `ReviewCardSubscriber` → AI Workflow → `ReviewCard` 的完整链路已追踪(见 7.7 节),但 ReviewCard 表无 `jobId` FK,无法溯源卡片生成来源
|
||||
14. **FocusItem 不走事件系统**:Worker 进程内直接同步调用 `focusItems.create()`,无可靠投递保证——M-AI-05 Outbox 化时需考虑
|
||||
15. **AiUsageLog 低成功率根因已定位**:72.7% 失败由 MiniMax M2.7 API Key 到期(2026-06-06)导致 — `learning-trend` 全部使用 minimax 故失败率 84%。DeepSeek 调用近乎 100% 成功。生产已全量切换至 DeepSeek V4 Pro/Flash,M-AI-02 无需保留 Minimax 兼容
|
||||
Loading…
x
Reference in New Issue
Block a user