Compare commits
7 Commits
789d6ec15c
...
27fe009e75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fe009e75 | ||
|
|
0dab626cd8 | ||
|
|
7cc6947e41 | ||
|
|
1fadb724ec | ||
|
|
f4259e70a0 | ||
|
|
0f1f2e5123 | ||
|
|
800ce4a2b2 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,6 +41,9 @@ prisma/seed.d.ts
|
|||||||
*-error-*.png
|
*-error-*.png
|
||||||
*-run-*.png
|
*-run-*.png
|
||||||
|
|
||||||
|
# Audit scripts (ephemeral, may contain credentials in env vars)
|
||||||
|
scripts/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.cache/
|
.cache/
|
||||||
tmp/
|
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 兼容
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
-- M-AI-02-03: Expand AiAnalysisJob 身份、路由与幂等字段
|
||||||
|
-- ADR-002 §2.1 身份与路由字段集
|
||||||
|
|
||||||
|
-- 1. jobType 扩展 VARCHAR(32) → VARCHAR(64)
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
MODIFY COLUMN `jobType` VARCHAR(64) NOT NULL;
|
||||||
|
|
||||||
|
-- 2. 新增身份与路由字段
|
||||||
|
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 `priority` INT NOT NULL DEFAULT 0 AFTER `queueName`,
|
||||||
|
ADD COLUMN `targetType` VARCHAR(32) NULL AFTER `priority`,
|
||||||
|
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`,
|
||||||
|
ADD COLUMN `inputRef` VARCHAR(255) NULL AFTER `retriedFromJobId`,
|
||||||
|
ADD COLUMN `inputSchemaVersion` VARCHAR(100) NULL AFTER `inputRef`;
|
||||||
|
|
||||||
|
-- 3. Unique 约束(idempotencyKey — MySQL UNIQUE 允许多个 NULL)
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD UNIQUE INDEX `AiAnalysisJob_idempotencyKey_key` (`idempotencyKey`);
|
||||||
|
|
||||||
|
-- 4. 复合索引
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD INDEX `AiAnalysisJob_userId_jobType_createdAt_idx` (`userId`, `jobType`, `createdAt`),
|
||||||
|
ADD INDEX `AiAnalysisJob_targetType_targetId_idx` (`targetType`, `targetId`),
|
||||||
|
ADD INDEX `AiAnalysisJob_parentJobId_idx` (`parentJobId`);
|
||||||
|
|
||||||
|
-- 5. 自引用 FK(parentJobId → id)
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD CONSTRAINT `AiAnalysisJob_parentJobId_fkey`
|
||||||
|
FOREIGN KEY (`parentJobId`) REFERENCES `AiAnalysisJob`(`id`)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
-- M-AI-02-04: Expand AiAnalysisJob 生命周期、执行与输出字段
|
||||||
|
-- ADR-002 §2.1 生命周期/执行/凭据/输出字段集
|
||||||
|
|
||||||
|
-- 1. 生命周期与执行字段
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD COLUMN `lifecycleStatus` VARCHAR(32) NULL AFTER `status`,
|
||||||
|
ADD COLUMN `lockedBy` VARCHAR(100) NULL AFTER `lifecycleStatus`,
|
||||||
|
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 `cancelRequestedAt`,
|
||||||
|
ADD COLUMN `maxAttempts` INT NOT NULL DEFAULT 3 AFTER `attemptCount`,
|
||||||
|
ADD COLUMN `timeoutMs` INT NOT NULL DEFAULT 120000 AFTER `maxAttempts`;
|
||||||
|
|
||||||
|
-- 2. 凭据与模型字段
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD COLUMN `credentialMode` VARCHAR(32) NOT NULL DEFAULT 'platform_key' AFTER `timeoutMs`,
|
||||||
|
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`;
|
||||||
|
|
||||||
|
-- 3. 输入输出字段
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD COLUMN `promptKey` VARCHAR(128) NULL AFTER `modelName`,
|
||||||
|
ADD COLUMN `promptVersion` VARCHAR(100) NULL AFTER `promptKey`,
|
||||||
|
ADD COLUMN `outputSchemaVersion` VARCHAR(100) NULL AFTER `promptVersion`,
|
||||||
|
ADD COLUMN `outputHash` VARCHAR(255) NULL AFTER `outputSchemaVersion`,
|
||||||
|
ADD COLUMN `validatedOutput` JSON NULL AFTER `outputHash`;
|
||||||
|
|
||||||
|
-- 4. 错误字段 + 列重命名
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD COLUMN `errorCode` VARCHAR(100) NULL AFTER `validatedOutput`,
|
||||||
|
ADD COLUMN `publicErrorMessage` VARCHAR(500) NULL AFTER `errorCode`;
|
||||||
|
|
||||||
|
-- 4a. 列重命名 1/2: errorMessage → internalErrorMessage
|
||||||
|
-- 理由:区分公开错误(publicErrorMessage)与内部错误(internalErrorMessage)
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
CHANGE COLUMN `errorMessage` `internalErrorMessage` TEXT NULL;
|
||||||
|
|
||||||
|
-- 4b. 列重命名 2/2: completedAt → finishedAt
|
||||||
|
-- 理由:与 lifecycleStatus 'succeeded' 去歧义,与 startedAt 配对
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
CHANGE COLUMN `completedAt` `finishedAt` DATETIME(3) NULL;
|
||||||
|
|
||||||
|
-- 5. 索引:Worker 轮询
|
||||||
|
ALTER TABLE `AiAnalysisJob`
|
||||||
|
ADD INDEX `AiAnalysisJob_lifecycleStatus_createdAt_idx` (`lifecycleStatus`, `createdAt`),
|
||||||
|
ADD INDEX `AiAnalysisJob_queueName_lifecycleStatus_createdAt_idx` (`queueName`, `lifecycleStatus`, `createdAt`);
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
-- M-AI-02-05: 新增不可变 AiJobSnapshot 数据模型
|
||||||
|
-- ADR-002 §6 删除策略:CASCADE on Job delete
|
||||||
|
|
||||||
|
CREATE TABLE `AiJobSnapshot` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`jobId` VARCHAR(191) NOT NULL,
|
||||||
|
`jobType` VARCHAR(64) NOT NULL,
|
||||||
|
`schemaVersion` VARCHAR(100) NOT NULL,
|
||||||
|
`redactionVersion` VARCHAR(100) NULL,
|
||||||
|
`content` JSON NOT NULL,
|
||||||
|
`contentHash` VARCHAR(255) NULL,
|
||||||
|
`expiresAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `AiJobSnapshot_jobId_key` (`jobId`),
|
||||||
|
INDEX `AiJobSnapshot_jobType_idx` (`jobType`),
|
||||||
|
INDEX `AiJobSnapshot_expiresAt_idx` (`expiresAt`),
|
||||||
|
INDEX `AiJobSnapshot_createdAt_idx` (`createdAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- FK: Job 删除时级联删 Snapshot(ADR-002 §6)
|
||||||
|
ALTER TABLE `AiJobSnapshot`
|
||||||
|
ADD CONSTRAINT `AiJobSnapshot_jobId_fkey`
|
||||||
|
FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
-- M-AI-02-06: 新增 AiJobArtifact 产物关联模型(多态引用)
|
||||||
|
-- ADR-002 §6 删除策略:CASCADE on Job delete
|
||||||
|
|
||||||
|
CREATE TABLE `AiJobArtifact` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`jobId` VARCHAR(191) NOT NULL,
|
||||||
|
`artifactType` VARCHAR(32) NOT NULL,
|
||||||
|
`artifactId` VARCHAR(255) NOT NULL,
|
||||||
|
`ordinal` INT NOT NULL DEFAULT 0,
|
||||||
|
`metadata` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `AiJobArtifact_jobId_artifactType_artifactId_key` (`jobId`, `artifactType`, `artifactId`),
|
||||||
|
INDEX `AiJobArtifact_jobId_ordinal_idx` (`jobId`, `ordinal`),
|
||||||
|
INDEX `AiJobArtifact_artifactType_artifactId_idx` (`artifactType`, `artifactId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- FK: Job 删除时级联删 Artifact(ADR-002 §6)
|
||||||
|
ALTER TABLE `AiJobArtifact`
|
||||||
|
ADD CONSTRAINT `AiJobArtifact_jobId_fkey`
|
||||||
|
FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
-- M-AI-02-07: 新增 Transactional Outbox 数据模型
|
||||||
|
-- 用于可靠发布 BullMQ Job 和领域事件(本批不实现 Dispatcher)
|
||||||
|
|
||||||
|
CREATE TABLE `OutboxEvent` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`eventType` VARCHAR(128) NOT NULL,
|
||||||
|
`aggregateType` VARCHAR(64) NOT NULL,
|
||||||
|
`aggregateId` VARCHAR(255) NOT NULL,
|
||||||
|
`dedupeKey` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
`attemptCount` INT NOT NULL DEFAULT 0,
|
||||||
|
`availableAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`lockedAt` DATETIME(3) NULL,
|
||||||
|
`lockedBy` VARCHAR(100) NULL,
|
||||||
|
`publishedAt` DATETIME(3) NULL,
|
||||||
|
`lastErrorCode` VARCHAR(100) NULL,
|
||||||
|
`lastErrorMessage` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `OutboxEvent_dedupeKey_key` (`dedupeKey`),
|
||||||
|
INDEX `OutboxEvent_status_availableAt_idx` (`status`, `availableAt`),
|
||||||
|
INDEX `OutboxEvent_lockedAt_idx` (`lockedAt`),
|
||||||
|
INDEX `OutboxEvent_aggregateType_aggregateId_idx` (`aggregateType`, `aggregateId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- M-AI-02-08: 扩展 AiUsageLog 的 Job、尝试次数与凭据归属
|
||||||
|
|
||||||
|
-- 1. 新增 Job 关联与凭据归属字段
|
||||||
|
ALTER TABLE `AiUsageLog`
|
||||||
|
ADD COLUMN `jobId` VARCHAR(191) NULL AFTER `errorMessage`,
|
||||||
|
ADD COLUMN `attemptNo` INT NOT NULL DEFAULT 0 AFTER `jobId`,
|
||||||
|
ADD COLUMN `credentialMode` VARCHAR(32) NOT NULL DEFAULT 'platform_key' AFTER `attemptNo`,
|
||||||
|
ADD COLUMN `errorCode` VARCHAR(100) NULL AFTER `credentialMode`,
|
||||||
|
ADD COLUMN `providerRequestId` VARCHAR(255) NULL AFTER `errorCode`;
|
||||||
|
|
||||||
|
-- 2. FK: Job 删除时 SetNull UsageLog(保留审计记录)
|
||||||
|
ALTER TABLE `AiUsageLog`
|
||||||
|
ADD CONSTRAINT `AiUsageLog_jobId_fkey`
|
||||||
|
FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- 3. 索引
|
||||||
|
ALTER TABLE `AiUsageLog`
|
||||||
|
ADD INDEX `AiUsageLog_jobId_attemptNo_idx` (`jobId`, `attemptNo`),
|
||||||
|
ADD INDEX `AiUsageLog_provider_model_createdAt_idx` (`provider`, `model`, `createdAt`),
|
||||||
|
ADD INDEX `AiUsageLog_errorCode_createdAt_idx` (`errorCode`, `createdAt`);
|
||||||
@ -40,7 +40,7 @@ model User {
|
|||||||
activeRecallQuestions ActiveRecallQuestion[]
|
activeRecallQuestions ActiveRecallQuestion[]
|
||||||
quizAttempts QuizAttempt[]
|
quizAttempts QuizAttempt[]
|
||||||
activeRecallAnswers ActiveRecallAnswer[]
|
activeRecallAnswers ActiveRecallAnswer[]
|
||||||
aiAnalysisJobs AiAnalysisJob[]
|
aiJobs AiJob[]
|
||||||
aiAnalysisResults AiAnalysisResult[]
|
aiAnalysisResults AiAnalysisResult[]
|
||||||
focusItems FocusItem[]
|
focusItems FocusItem[]
|
||||||
reviewCards ReviewCard[]
|
reviewCards ReviewCard[]
|
||||||
@ -564,27 +564,114 @@ model ActiveRecallAnswer {
|
|||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AiAnalysisJob {
|
model AiJob {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
sessionId String?
|
sessionId String?
|
||||||
answerId String?
|
answerId String?
|
||||||
jobType String @db.VarChar(32)
|
jobType String @db.VarChar(64)
|
||||||
|
// ── M-AI-02-03: 身份与路由 ──
|
||||||
|
triggerType String @default("api") @db.VarChar(32)
|
||||||
|
queueName String @default("ai-interactive") @db.VarChar(64)
|
||||||
|
priority Int @default(0)
|
||||||
|
targetType String? @db.VarChar(32)
|
||||||
|
targetId String? @db.VarChar(255)
|
||||||
|
parentJobId String? @db.VarChar(255)
|
||||||
|
idempotencyKey String? @unique @db.VarChar(255)
|
||||||
|
retriedFromJobId String? @db.VarChar(255)
|
||||||
|
inputRef String? @db.VarChar(255)
|
||||||
|
inputSchemaVersion String? @db.VarChar(100)
|
||||||
|
// ── 原有字段 ──
|
||||||
status String @default("pending") @db.VarChar(32)
|
status String @default("pending") @db.VarChar(32)
|
||||||
|
// ── M-AI-02-04: 生命周期与执行 ──
|
||||||
|
lifecycleStatus String? @db.VarChar(32)
|
||||||
|
lockedBy String? @db.VarChar(100)
|
||||||
|
lockedAt DateTime?
|
||||||
|
lockUntil DateTime?
|
||||||
|
cancelRequestedAt DateTime?
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
maxAttempts Int @default(3)
|
||||||
|
timeoutMs Int @default(120000)
|
||||||
|
// ── M-AI-02-04: 凭据与模型 ──
|
||||||
|
credentialMode String @default("platform_key") @db.VarChar(32)
|
||||||
|
credentialId String? @db.VarChar(255)
|
||||||
|
modelTier String @default("primary") @db.VarChar(32)
|
||||||
|
modelProvider String @default("deepseek") @db.VarChar(32)
|
||||||
|
modelName String @default("deepseek-chat") @db.VarChar(64)
|
||||||
|
// ── M-AI-02-04: 输入输出 ──
|
||||||
|
promptKey String? @db.VarChar(128)
|
||||||
|
promptVersion String? @db.VarChar(100)
|
||||||
|
outputSchemaVersion String? @db.VarChar(100)
|
||||||
|
outputHash String? @db.VarChar(255)
|
||||||
|
validatedOutput Json?
|
||||||
|
// ── M-AI-02-04: 错误信息 ──
|
||||||
|
errorCode String? @db.VarChar(100)
|
||||||
|
publicErrorMessage String? @db.VarChar(500)
|
||||||
|
internalErrorMessage String? @db.Text
|
||||||
|
// ── 时间戳 ──
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
errorMessage String? @db.Text
|
|
||||||
queuedAt DateTime?
|
queuedAt DateTime?
|
||||||
startedAt DateTime?
|
startedAt DateTime?
|
||||||
completedAt DateTime?
|
finishedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
results AiAnalysisResult[]
|
results AiAnalysisResult[]
|
||||||
|
snapshot AiJobSnapshot?
|
||||||
|
artifacts AiJobArtifact[]
|
||||||
|
usageLogs AiUsageLog[]
|
||||||
|
parentJob AiJob? @relation("JobChildren", fields: [parentJobId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
children AiJob[] @relation("JobChildren")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
|
@@index([userId, jobType, createdAt])
|
||||||
|
@@index([targetType, targetId])
|
||||||
|
@@index([parentJobId])
|
||||||
|
@@index([lifecycleStatus, createdAt])
|
||||||
|
@@index([queueName, lifecycleStatus, createdAt])
|
||||||
|
|
||||||
|
@@map("AiAnalysisJob")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── M-AI-02-05: 不可变 Job 输入快照 ──
|
||||||
|
|
||||||
|
model AiJobSnapshot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
jobId String @unique
|
||||||
|
jobType String @db.VarChar(64)
|
||||||
|
schemaVersion String @db.VarChar(100)
|
||||||
|
redactionVersion String? @db.VarChar(100)
|
||||||
|
content Json
|
||||||
|
contentHash String? @db.VarChar(255)
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
job AiJob @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([jobType])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── M-AI-02-06: 产物关联模型(多态引用)──
|
||||||
|
|
||||||
|
model AiJobArtifact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
jobId String
|
||||||
|
artifactType String @db.VarChar(32)
|
||||||
|
artifactId String @db.VarChar(255)
|
||||||
|
ordinal Int @default(0)
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
job AiJob @relation(fields: [jobId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@unique([jobId, artifactType, artifactId])
|
||||||
|
@@index([jobId, ordinal])
|
||||||
|
@@index([artifactType, artifactId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AiAnalysisResult {
|
model AiAnalysisResult {
|
||||||
@ -604,7 +691,7 @@ model AiAnalysisResult {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
job AiAnalysisJob @relation(fields: [jobId], references: [id])
|
job AiJob @relation(fields: [jobId], references: [id])
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([jobId])
|
@@index([jobId])
|
||||||
@ -780,13 +867,23 @@ model AiUsageLog {
|
|||||||
latencyMs Int @default(0)
|
latencyMs Int @default(0)
|
||||||
success Boolean @default(true)
|
success Boolean @default(true)
|
||||||
errorMessage String? @db.VarChar(500)
|
errorMessage String? @db.VarChar(500)
|
||||||
|
// ── M-AI-02-08: Job 关联与凭据归属 ──
|
||||||
|
jobId String? @db.VarChar(191)
|
||||||
|
attemptNo Int @default(0)
|
||||||
|
credentialMode String @default("platform_key") @db.VarChar(32)
|
||||||
|
errorCode String? @db.VarChar(100)
|
||||||
|
providerRequestId String? @db.VarChar(255)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
job AiJob? @relation(fields: [jobId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([feature])
|
@@index([feature])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([jobId, attemptNo])
|
||||||
|
@@index([provider, model, createdAt])
|
||||||
|
@@index([errorCode, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ModelRoute {
|
model ModelRoute {
|
||||||
@ -2218,3 +2315,28 @@ model AiArtifactFeedback {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([artifactType])
|
@@index([artifactType])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── M-AI-02-07: Transactional Outbox ──
|
||||||
|
|
||||||
|
model OutboxEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventType String @db.VarChar(128)
|
||||||
|
aggregateType String @db.VarChar(64)
|
||||||
|
aggregateId String @db.VarChar(255)
|
||||||
|
dedupeKey String @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
status String @default("pending") @db.VarChar(32)
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
lockedAt DateTime?
|
||||||
|
lockedBy String? @db.VarChar(100)
|
||||||
|
publishedAt DateTime?
|
||||||
|
lastErrorCode String? @db.VarChar(100)
|
||||||
|
lastErrorMessage String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([lockedAt])
|
||||||
|
@@index([aggregateType, aggregateId])
|
||||||
|
}
|
||||||
|
|||||||
178
src/infrastructure/outbox/outbox.repository.spec.ts
Normal file
178
src/infrastructure/outbox/outbox.repository.spec.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { OutboxRepository, CreateOutboxInput } from './outbox.repository';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
|
||||||
|
describe('OutboxRepository', () => {
|
||||||
|
let repo: OutboxRepository;
|
||||||
|
|
||||||
|
const mockOutboxCreate = jest.fn();
|
||||||
|
const mockOutboxFindMany = jest.fn();
|
||||||
|
const mockOutboxFindUnique = jest.fn();
|
||||||
|
const mockOutboxUpdate = jest.fn();
|
||||||
|
const mockOutboxUpdateMany = jest.fn();
|
||||||
|
|
||||||
|
const mockTx = {
|
||||||
|
outboxEvent: {
|
||||||
|
create: mockOutboxCreate,
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockOutboxCreate.mockReset();
|
||||||
|
mockOutboxFindMany.mockReset();
|
||||||
|
mockOutboxFindUnique.mockReset();
|
||||||
|
mockOutboxUpdate.mockReset();
|
||||||
|
mockOutboxUpdateMany.mockReset();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OutboxRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
outboxEvent: {
|
||||||
|
create: mockOutboxCreate,
|
||||||
|
findMany: mockOutboxFindMany,
|
||||||
|
findUnique: mockOutboxFindUnique,
|
||||||
|
update: mockOutboxUpdate,
|
||||||
|
updateMany: mockOutboxUpdateMany,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repo = module.get(OutboxRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sampleInput: CreateOutboxInput = {
|
||||||
|
eventType: 'ai.job.completed',
|
||||||
|
aggregateType: 'AiJob',
|
||||||
|
aggregateId: 'job-1',
|
||||||
|
dedupeKey: 'job-1:completed:v1',
|
||||||
|
payload: { jobId: 'job-1', status: 'succeeded' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createInTransaction', () => {
|
||||||
|
it('应在事务中创建 Outbox 事件', async () => {
|
||||||
|
mockOutboxCreate.mockResolvedValue({ id: 'evt-1', ...sampleInput, status: 'pending', createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.createInTransaction(mockTx, sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'evt-1');
|
||||||
|
expect(mockOutboxCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('事务回滚时 Outbox 不存在(duplicate key 测试)', async () => {
|
||||||
|
// 模拟:事务中先写 Job,再写 Outbox 时遇到 dedupeKey 冲突
|
||||||
|
// 事务整体回滚,Job 也不存在
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('dup', {
|
||||||
|
code: 'P2002', clientVersion: '5.22.0', meta: {},
|
||||||
|
});
|
||||||
|
mockOutboxCreate.mockRejectedValueOnce(p2002);
|
||||||
|
|
||||||
|
await expect(repo.createInTransaction(mockTx, sampleInput)).rejects.toThrow();
|
||||||
|
// 调用方应回滚整个事务
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDispatchable', () => {
|
||||||
|
it('应返回 pending 且 availableAt 已到的事件', async () => {
|
||||||
|
mockOutboxFindMany.mockResolvedValue([]);
|
||||||
|
await repo.findDispatchable(20);
|
||||||
|
expect(mockOutboxFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'pending', availableAt: { lte: expect.any(Date) } },
|
||||||
|
orderBy: { availableAt: 'asc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markProcessing(CAS)', () => {
|
||||||
|
it('CAS 成功(status=pending)应获取锁并返回记录', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockOutboxFindUnique.mockResolvedValue({ id: 'evt-1', status: 'processing', lockedBy: 'worker-1' });
|
||||||
|
|
||||||
|
const result = await repo.markProcessing('evt-1', 'worker-1');
|
||||||
|
expect(result).toHaveProperty('status', 'processing');
|
||||||
|
expect(mockOutboxUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'evt-1', status: 'pending' },
|
||||||
|
data: { status: 'processing', lockedAt: expect.any(Date), lockedBy: 'worker-1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CAS 失败(已被其他 Worker 抢占)应返回 null', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 0 }); // 另一个 Worker 已改 status
|
||||||
|
|
||||||
|
const result = await repo.markProcessing('evt-1', 'worker-2');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockOutboxUpdateMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOutboxFindUnique).not.toHaveBeenCalled(); // 未获得锁,不查询
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markPublished', () => {
|
||||||
|
it('应设置 status=published, publishedAt', async () => {
|
||||||
|
mockOutboxUpdate.mockResolvedValue({ id: 'evt-1', status: 'published' });
|
||||||
|
const result = await repo.markPublished('evt-1');
|
||||||
|
expect(result).toHaveProperty('status', 'published');
|
||||||
|
expect(mockOutboxUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'evt-1' },
|
||||||
|
data: expect.objectContaining({ status: 'published' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markFailed', () => {
|
||||||
|
it('应设置 status=failed, 递增 attemptCount', async () => {
|
||||||
|
mockOutboxUpdate.mockResolvedValue({ id: 'evt-1', status: 'failed' });
|
||||||
|
const result = await repo.markFailed('evt-1', 'PUBLISH_ERR', 'timeout');
|
||||||
|
expect(result).toHaveProperty('status', 'failed');
|
||||||
|
expect(mockOutboxUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'evt-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
lastErrorCode: 'PUBLISH_ERR',
|
||||||
|
lastErrorMessage: 'timeout',
|
||||||
|
attemptCount: { increment: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('releaseExpiredLocks', () => {
|
||||||
|
it('应释放超过阈值的 processing 事件锁', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 3 });
|
||||||
|
const count = await repo.releaseExpiredLocks(60_000);
|
||||||
|
expect(count).toBe(3);
|
||||||
|
expect(mockOutboxUpdateMany).toHaveBeenCalledWith({
|
||||||
|
where: { status: 'processing', lockedAt: { lt: expect.any(Date) } },
|
||||||
|
data: { status: 'pending', lockedAt: null, lockedBy: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回 0 当无过期锁', async () => {
|
||||||
|
mockOutboxUpdateMany.mockResolvedValue({ count: 0 });
|
||||||
|
const count = await repo.releaseExpiredLocks();
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('约束验证', () => {
|
||||||
|
it('不应暴露 publish/publishToQueue 方法', () => {
|
||||||
|
expect(typeof (repo as any).publish).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).publishToQueue).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).sendToBullMQ).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不应暴露独立事务方法', () => {
|
||||||
|
expect(typeof (repo as any).create).toBe('undefined');
|
||||||
|
// createInTransaction 必须接受 tx 参数,不允许无 tx 调用
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupeKey UNIQUE 由 Prisma schema 保证', () => {
|
||||||
|
// OutboxEvent.dedupeKey @unique — 数据库层保证
|
||||||
|
expect(repo).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/infrastructure/outbox/outbox.repository.ts
Normal file
139
src/infrastructure/outbox/outbox.repository.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export const OUTBOX_STATUSES = ['pending', 'processing', 'published', 'failed'] as const;
|
||||||
|
export type OutboxStatus = (typeof OUTBOX_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface CreateOutboxInput {
|
||||||
|
eventType: string;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
dedupeKey: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
availableAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-02-07: Transactional Outbox Repository
|
||||||
|
*
|
||||||
|
* 约束:
|
||||||
|
* - createInTransaction 支持在外部 Prisma Transaction 中原子写入
|
||||||
|
* - 不自行开启独立事务
|
||||||
|
* - 不向 BullMQ 发布
|
||||||
|
* - dedupeKey UNIQUE 保证幂等
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在给定的事务客户端中创建 Outbox 事件。
|
||||||
|
* 若未传 tx,使用 this.prisma(非事务模式,仅测试用)。
|
||||||
|
*
|
||||||
|
* 调用方负责在同一个 tx 中同时写入业务数据,
|
||||||
|
* 以确保 Job + Outbox 的原子性。
|
||||||
|
*/
|
||||||
|
async createInTransaction(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: CreateOutboxInput,
|
||||||
|
) {
|
||||||
|
return tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: input.eventType,
|
||||||
|
aggregateType: input.aggregateType,
|
||||||
|
aggregateId: input.aggregateId,
|
||||||
|
dedupeKey: input.dedupeKey,
|
||||||
|
payload: input.payload as any,
|
||||||
|
availableAt: input.availableAt ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找可分发的事件:status=pending 且 availableAt 已到。
|
||||||
|
* 按 availableAt ASC 保证 FIFO。
|
||||||
|
*/
|
||||||
|
async findDispatchable(limit: number = 50) {
|
||||||
|
return this.prisma.outboxEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'pending',
|
||||||
|
availableAt: { lte: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: { availableAt: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为 processing(CAS:仅当 status 仍为 'pending' 时成功)。
|
||||||
|
* 返回更新后的记录;若被其他 Worker 抢占则返回 null。
|
||||||
|
*/
|
||||||
|
async markProcessing(eventId: string, lockedBy: string) {
|
||||||
|
const result = await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
id: eventId,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
lockedAt: new Date(),
|
||||||
|
lockedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) return null;
|
||||||
|
|
||||||
|
return this.prisma.outboxEvent.findUnique({
|
||||||
|
where: { id: eventId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记为 published */
|
||||||
|
async markPublished(eventId: string) {
|
||||||
|
return this.prisma.outboxEvent.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: {
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date(),
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记为 failed,递增 attemptCount */
|
||||||
|
async markFailed(eventId: string, errorCode?: string, errorMessage?: string) {
|
||||||
|
return this.prisma.outboxEvent.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
lastErrorCode: errorCode,
|
||||||
|
lastErrorMessage: errorMessage,
|
||||||
|
attemptCount: { increment: 1 },
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放过期锁:将 lockedAt 超过 thresholdMs 的 processing 事件重置为 pending。
|
||||||
|
* 返回释放数量。
|
||||||
|
*/
|
||||||
|
async releaseExpiredLocks(thresholdMs: number = 60_000) {
|
||||||
|
const cutoff = new Date(Date.now() - thresholdMs);
|
||||||
|
const result = await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
status: 'processing',
|
||||||
|
lockedAt: { lt: cutoff },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'pending',
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,16 @@ import { PrismaService } from '../../infrastructure/database/prisma.service';
|
|||||||
export class AiAnalysisRepository {
|
export class AiAnalysisRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ── M-AI-02-10: 旧状态 → 新 lifecycleStatus 映射 ──
|
||||||
|
private static readonly STATUS_TO_LIFECYCLE: Record<string, string> = {
|
||||||
|
pending: 'pending',
|
||||||
|
processing: 'running',
|
||||||
|
completed: 'succeeded',
|
||||||
|
failed: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
async createJob(userId: string, jobType: string, sessionId?: string, answerId?: string) {
|
async createJob(userId: string, jobType: string, sessionId?: string, answerId?: string) {
|
||||||
return this.prisma.aiAnalysisJob.create({
|
return this.prisma.aiJob.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
jobType,
|
jobType,
|
||||||
@ -14,6 +22,12 @@ export class AiAnalysisRepository {
|
|||||||
answerId: answerId ?? null,
|
answerId: answerId ?? null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
queuedAt: new Date(),
|
queuedAt: new Date(),
|
||||||
|
// ── M-AI-02-10 Shadow Write ──
|
||||||
|
lifecycleStatus: 'pending',
|
||||||
|
triggerType: 'api',
|
||||||
|
queueName: 'ai-interactive',
|
||||||
|
inputSchemaVersion: 'legacy-v1',
|
||||||
|
attemptCount: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -21,13 +35,18 @@ export class AiAnalysisRepository {
|
|||||||
async updateJobStatus(id: string, status: string, errorMessage?: string) {
|
async updateJobStatus(id: string, status: string, errorMessage?: string) {
|
||||||
const data: Record<string, any> = { status };
|
const data: Record<string, any> = { status };
|
||||||
if (status === 'processing') data.startedAt = new Date();
|
if (status === 'processing') data.startedAt = new Date();
|
||||||
if (status === 'completed' || status === 'failed') data.completedAt = new Date();
|
if (status === 'completed' || status === 'failed') data.finishedAt = new Date();
|
||||||
if (errorMessage) data.errorMessage = errorMessage;
|
if (errorMessage) data.internalErrorMessage = errorMessage;
|
||||||
return this.prisma.aiAnalysisJob.update({ where: { id }, data });
|
|
||||||
|
// ── M-AI-02-10 Shadow Write:映射旧 status 到新 lifecycleStatus ──
|
||||||
|
const lifecycleStatus = AiAnalysisRepository.STATUS_TO_LIFECYCLE[status];
|
||||||
|
if (lifecycleStatus) data.lifecycleStatus = lifecycleStatus;
|
||||||
|
|
||||||
|
return this.prisma.aiJob.update({ where: { id }, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findJobById(id: string) {
|
async findJobById(id: string) {
|
||||||
return this.prisma.aiAnalysisJob.findUnique({
|
return this.prisma.aiJob.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { results: true },
|
include: { results: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -60,8 +60,8 @@ export class AiAnalysisService {
|
|||||||
status: job.status,
|
status: job.status,
|
||||||
queuedAt: job.queuedAt,
|
queuedAt: job.queuedAt,
|
||||||
startedAt: job.startedAt,
|
startedAt: job.startedAt,
|
||||||
completedAt: job.completedAt,
|
completedAt: job.finishedAt,
|
||||||
errorMessage: job.errorMessage,
|
errorMessage: job.internalErrorMessage,
|
||||||
results: job.results,
|
results: job.results,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
186
src/modules/ai-runtime/ai-job-artifact.repository.spec.ts
Normal file
186
src/modules/ai-runtime/ai-job-artifact.repository.spec.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AiJobArtifactRepository, ARTIFACT_TYPES, CreateArtifactInput } from './ai-job-artifact.repository';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
describe('AiJobArtifactRepository', () => {
|
||||||
|
let repo: AiJobArtifactRepository;
|
||||||
|
let mockCreate: jest.Mock;
|
||||||
|
let mockFindUnique: jest.Mock;
|
||||||
|
let mockFindMany: jest.Mock;
|
||||||
|
|
||||||
|
const sampleInput: CreateArtifactInput = {
|
||||||
|
jobId: 'job-1',
|
||||||
|
artifactType: 'quiz',
|
||||||
|
artifactId: 'quiz-42',
|
||||||
|
ordinal: 0,
|
||||||
|
metadata: { questionCount: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockCreate = jest.fn();
|
||||||
|
mockFindUnique = jest.fn();
|
||||||
|
mockFindMany = jest.fn();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiJobArtifactRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
aiJobArtifact: {
|
||||||
|
create: mockCreate,
|
||||||
|
findUnique: mockFindUnique,
|
||||||
|
findMany: mockFindMany,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repo = module.get(AiJobArtifactRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create(幂等)', () => {
|
||||||
|
it('应创建 Artifact 并返回记录', async () => {
|
||||||
|
mockCreate.mockResolvedValue({ id: 'art-1', ...sampleInput, createdAt: new Date() });
|
||||||
|
const result = await repo.create(sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'art-1');
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复创建同一 (jobId, artifactType, artifactId) 应幂等返回已有记录', async () => {
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint', {
|
||||||
|
code: 'P2002',
|
||||||
|
clientVersion: '5.22.0',
|
||||||
|
meta: { target: ['jobId_artifactType_artifactId'] },
|
||||||
|
});
|
||||||
|
mockCreate.mockRejectedValueOnce(p2002);
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'art-1', ...sampleInput, createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.create(sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'art-1');
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2002 后 findUnique 返回 null(CASCADE 竞态)应重试 create 并成功', async () => {
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint', {
|
||||||
|
code: 'P2002', clientVersion: '5.22.0', meta: { target: ['jobId_artifactType_artifactId'] },
|
||||||
|
});
|
||||||
|
mockCreate.mockRejectedValueOnce(p2002);
|
||||||
|
mockFindUnique.mockResolvedValueOnce(null); // CASCADE 竞态:记录已被删除
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-retry', ...sampleInput, createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.create(sampleInput);
|
||||||
|
expect(result).toHaveProperty('id', 'art-retry');
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(2); // 原 create + 重试
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非 P2002 错误应抛出', async () => {
|
||||||
|
const otherError = new Error('DB connection lost');
|
||||||
|
mockCreate.mockRejectedValueOnce(otherError);
|
||||||
|
await expect(repo.create(sampleInput)).rejects.toThrow('DB connection lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应只接受 ARTIFACT_TYPES 中定义的类型', () => {
|
||||||
|
for (const t of ARTIFACT_TYPES) {
|
||||||
|
const input: CreateArtifactInput = { jobId: 'j', artifactType: t, artifactId: 'a' };
|
||||||
|
expect(() => repo.create(input)).toBeDefined();
|
||||||
|
}
|
||||||
|
// @ts-expect-error - 编译期类型检查,运行时不拒绝但应通过 review 阻止
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMany', () => {
|
||||||
|
it('应批量创建多个不同类型的 Artifact', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-1', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, createdAt: new Date() });
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-2', jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 1, createdAt: new Date() });
|
||||||
|
|
||||||
|
const inputs: CreateArtifactInput[] = [
|
||||||
|
{ jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0 },
|
||||||
|
{ jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 1 },
|
||||||
|
];
|
||||||
|
const results = await repo.createMany(inputs);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createMany 中部分重复应幂等处理', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({ id: 'art-1', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, createdAt: new Date() });
|
||||||
|
const p2002 = new Prisma.PrismaClientKnownRequestError('dup', { code: 'P2002', clientVersion: '5.22.0', meta: {} });
|
||||||
|
mockCreate.mockRejectedValueOnce(p2002);
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'art-2', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q2', ordinal: 1, createdAt: new Date() });
|
||||||
|
|
||||||
|
const inputs: CreateArtifactInput[] = [
|
||||||
|
{ jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1' },
|
||||||
|
{ jobId: 'job-1', artifactType: 'quiz', artifactId: 'q2' },
|
||||||
|
];
|
||||||
|
const results = await repo.createMany(inputs);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]!.id).toBe('art-1');
|
||||||
|
expect(results[1]!.id).toBe('art-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByJobId', () => {
|
||||||
|
it('应按 ordinal 排序返回产物列表', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'art-1', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'art-2', jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 1, metadata: null, createdAt: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await repo.findByJobId('job-1');
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(mockFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { jobId: 'job-1' },
|
||||||
|
orderBy: { ordinal: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回空数组当无产物', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
const results = await repo.findByJobId('empty-job');
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findJobsByArtifact', () => {
|
||||||
|
it('应按 artifactType + artifactId 反向查询', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ jobId: 'job-1', ordinal: 0, createdAt: new Date('2026-06-19') },
|
||||||
|
{ jobId: 'job-2', ordinal: 0, createdAt: new Date('2026-06-20') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await repo.findJobsByArtifact('quiz', 'quiz-42');
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(mockFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { artifactType: 'quiz', artifactId: 'quiz-42' },
|
||||||
|
select: { jobId: true, ordinal: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('约束验证', () => {
|
||||||
|
it('不允许直接修改或删除 Artifact', () => {
|
||||||
|
const forbidden = ['update', 'updateMany', 'delete', 'deleteMany', 'upsert'];
|
||||||
|
for (const m of forbidden) {
|
||||||
|
expect(typeof (repo as any)[m]).toBe('undefined');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一个 Job 可关联多个不同类型产物', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'a', jobId: 'job-1', artifactType: 'quiz', artifactId: 'q1', ordinal: 0, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'b', jobId: 'job-1', artifactType: 'focus_item', artifactId: 'fi1', ordinal: 1, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'c', jobId: 'job-1', artifactType: 'review_card', artifactId: 'rc1', ordinal: 2, metadata: null, createdAt: new Date() },
|
||||||
|
{ id: 'd', jobId: 'job-1', artifactType: 'analysis_result', artifactId: 'ar1', ordinal: 3, metadata: null, createdAt: new Date() },
|
||||||
|
]);
|
||||||
|
const results = await repo.findByJobId('job-1');
|
||||||
|
const types = results.map(r => r.artifactType);
|
||||||
|
expect(new Set(types).size).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
110
src/modules/ai-runtime/ai-job-artifact.repository.ts
Normal file
110
src/modules/ai-runtime/ai-job-artifact.repository.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ARTIFACT_TYPES = [
|
||||||
|
'analysis_result',
|
||||||
|
'focus_item',
|
||||||
|
'review_card',
|
||||||
|
'quiz',
|
||||||
|
'learning_analysis',
|
||||||
|
'recommendation',
|
||||||
|
'knowledge_import',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
|
||||||
|
|
||||||
|
export interface CreateArtifactInput {
|
||||||
|
jobId: string;
|
||||||
|
artifactType: ArtifactType;
|
||||||
|
artifactId: string;
|
||||||
|
ordinal?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-02-06: Job 产物关联 Repository
|
||||||
|
*
|
||||||
|
* 多态引用:artifactType + artifactId 关联业务表,不设 FK 到各类型表。
|
||||||
|
* 唯一约束 (jobId, artifactType, artifactId) 保证幂等。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiJobArtifactRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等创建:若已存在同 (jobId, artifactType, artifactId) 的记录则跳过。
|
||||||
|
* 使用 Prisma create + skip-duplicate 语义(Prisma 不支持 INSERT IGNORE MySQL 语法,
|
||||||
|
* 但 Prisma 的唯一约束错误可通过 try/catch 转为幂等)。
|
||||||
|
*/
|
||||||
|
async create(input: CreateArtifactInput) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.aiJobArtifact.create({
|
||||||
|
data: {
|
||||||
|
jobId: input.jobId,
|
||||||
|
artifactType: input.artifactType,
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
ordinal: input.ordinal ?? 0,
|
||||||
|
metadata: input.metadata ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
// 唯一约束冲突 = 已存在,幂等返回已有记录
|
||||||
|
const existing = await this.prisma.aiJobArtifact.findUnique({
|
||||||
|
where: {
|
||||||
|
jobId_artifactType_artifactId: {
|
||||||
|
jobId: input.jobId,
|
||||||
|
artifactType: input.artifactType,
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// 极边缘情况:P2002 和 findUnique 之间父 Job 被 CASCADE 删除。
|
||||||
|
// 此时记录已不存在,重试 create 一次。
|
||||||
|
return this.prisma.aiJobArtifact.create({
|
||||||
|
data: {
|
||||||
|
jobId: input.jobId,
|
||||||
|
artifactType: input.artifactType,
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
ordinal: input.ordinal ?? 0,
|
||||||
|
metadata: input.metadata ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量幂等创建(逐个 try/catch,小批量场景适用) */
|
||||||
|
async createMany(inputs: CreateArtifactInput[]) {
|
||||||
|
const results: Awaited<ReturnType<typeof this.create>>[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
results.push(await this.create(input));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 Job 查询所有产物,按 ordinal 排序 */
|
||||||
|
async findByJobId(jobId: string) {
|
||||||
|
return this.prisma.aiJobArtifact.findMany({
|
||||||
|
where: { jobId },
|
||||||
|
orderBy: { ordinal: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反向查询:按产物查找关联的 Job。
|
||||||
|
* 通过 (artifactType, artifactId) 复合索引高效查询。
|
||||||
|
*/
|
||||||
|
async findJobsByArtifact(artifactType: ArtifactType, artifactId: string) {
|
||||||
|
const artifacts = await this.prisma.aiJobArtifact.findMany({
|
||||||
|
where: { artifactType, artifactId },
|
||||||
|
select: { jobId: true, ordinal: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return artifacts;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts
Normal file
172
src/modules/ai-runtime/ai-job-snapshot.repository.spec.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AiJobSnapshotRepository } from './ai-job-snapshot.repository';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
describe('AiJobSnapshotRepository', () => {
|
||||||
|
let repo: AiJobSnapshotRepository;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockCreate = jest.fn();
|
||||||
|
const mockFindUnique = jest.fn();
|
||||||
|
const mockDeleteMany = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockCreate.mockReset();
|
||||||
|
mockFindUnique.mockReset();
|
||||||
|
mockDeleteMany.mockReset();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AiJobSnapshotRepository,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: {
|
||||||
|
aiJobSnapshot: {
|
||||||
|
create: mockCreate,
|
||||||
|
findUnique: mockFindUnique,
|
||||||
|
deleteMany: mockDeleteMany,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repo = module.get(AiJobSnapshotRepository);
|
||||||
|
prisma = module.get(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('应创建快照并返回完整记录', async () => {
|
||||||
|
const input = {
|
||||||
|
jobId: 'job-1',
|
||||||
|
jobType: 'learning_state_analysis',
|
||||||
|
schemaVersion: 'snapshot_v1',
|
||||||
|
content: { targetType: 'knowledge_base', targetId: 'kb-1' },
|
||||||
|
contentHash: 'sha256:abc',
|
||||||
|
};
|
||||||
|
const expected = { id: 'snap-1', ...input, createdAt: new Date() };
|
||||||
|
mockCreate.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await repo.create(input);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({ data: input });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应创建带 expiresAt 的快照', async () => {
|
||||||
|
const expiresAt = new Date('2026-09-01');
|
||||||
|
const input = {
|
||||||
|
jobId: 'job-2',
|
||||||
|
jobType: 'quiz_generation',
|
||||||
|
schemaVersion: 'snapshot_v1',
|
||||||
|
content: {},
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
mockCreate.mockResolvedValue({ id: 'snap-2', ...input, createdAt: new Date() });
|
||||||
|
|
||||||
|
const result = await repo.create(input);
|
||||||
|
expect(result.expiresAt).toEqual(expiresAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByJobId', () => {
|
||||||
|
it('应返回匹配的快照', async () => {
|
||||||
|
const snap = { id: 'snap-1', jobId: 'job-1', jobType: 'test', schemaVersion: 'v1', content: {}, createdAt: new Date() };
|
||||||
|
mockFindUnique.mockResolvedValue(snap);
|
||||||
|
|
||||||
|
const result = await repo.findByJobId('job-1');
|
||||||
|
expect(result).toEqual(snap);
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledWith({ where: { jobId: 'job-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回 null 当快照不存在', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
const result = await repo.findByJobId('nonexistent');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteExpired', () => {
|
||||||
|
it('应删除过期快照并返回删除数量', async () => {
|
||||||
|
const before = new Date('2026-06-01');
|
||||||
|
mockDeleteMany.mockResolvedValue({ count: 3 });
|
||||||
|
|
||||||
|
const count = await repo.deleteExpired(before);
|
||||||
|
expect(count).toBe(3);
|
||||||
|
expect(mockDeleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { expiresAt: { lt: before } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回 0 当无过期快照', async () => {
|
||||||
|
mockDeleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
const count = await repo.deleteExpired(new Date());
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('不可变约束', () => {
|
||||||
|
it('Repository 不应暴露 update 方法', () => {
|
||||||
|
expect(typeof (repo as any).update).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).updateContent).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).upsert).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Repository 不应暴露 overwrite 方法', () => {
|
||||||
|
expect(typeof (repo as any).overwrite).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).save).toBe('undefined');
|
||||||
|
expect(typeof (repo as any).replace).toBe('undefined');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('安全约束', () => {
|
||||||
|
it('Repository 不应暴露直接修改 content 的路径', () => {
|
||||||
|
// 不可变核心:写入后 content 不可变更。唯一写入口是 create()。
|
||||||
|
const dangerousMethods = ['update', 'updateContent', 'upsert', 'patch', 'save', 'replace', 'overwrite', 'setContent', 'modify'];
|
||||||
|
for (const m of dangerousMethods) {
|
||||||
|
expect(typeof (repo as any)[m]).toBe('undefined');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create 方法签名要求结构化 content(不可传裸字符串)', () => {
|
||||||
|
// content 类型为 Record<string, unknown>,编译期阻止裸字符串。
|
||||||
|
// 此处运行时验证方法存在且参数名称暗示结构化输入。
|
||||||
|
const createFn = repo.create as Function;
|
||||||
|
expect(createFn).toBeDefined();
|
||||||
|
expect(createFn.length).toBe(1); // 单参数 data object
|
||||||
|
});
|
||||||
|
|
||||||
|
it('仅有的写入口 create() 返回类型不包含 update/upsert 方法', () => {
|
||||||
|
// 验证 Repository 的公开 API 只暴露有限操作。
|
||||||
|
// 运行时无法阻止访问私有字段(JS 无 # 语法),保护靠 TypeScript 编译 + code review。
|
||||||
|
const allowedMethods = ['create', 'findByJobId', 'deleteExpired'];
|
||||||
|
const allProtoMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(repo))
|
||||||
|
.filter(n => n !== 'constructor');
|
||||||
|
const unexpected = allProtoMethods.filter(m => !allowedMethods.includes(m));
|
||||||
|
expect(unexpected).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('敏感字段扫描在 SnapshotBuilder 层实现(设计意图文档化)', () => {
|
||||||
|
// M-AI-02-05 范围:Repository 基础设施
|
||||||
|
// M-AI-02-10 范围:SnapshotBuilder 实现脱敏 + 拒绝
|
||||||
|
// 10 类禁止字段:apiKey, jwt, authorization, cookie, databaseUrl,
|
||||||
|
// connectionString, password, secretKey, credential,
|
||||||
|
// 以及任何匹配 /Bearer\s+|sk-[a-zA-Z0-9]{32,}/ 模式的值
|
||||||
|
expect(repo).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('删除策略', () => {
|
||||||
|
it('应通过 CASCADE 跟随 Job 删除(Prisma schema 定义 onDelete: Cascade)', () => {
|
||||||
|
// FK 在 Prisma schema 中定义:
|
||||||
|
// job AiAnalysisJob @relation(..., onDelete: Cascade)
|
||||||
|
// 此测试确认 Repository 不实现手动级联删除(由 DB 处理)
|
||||||
|
expect(typeof (repo as any).deleteByJobId).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteExpired 应允许独立清理过期快照', async () => {
|
||||||
|
mockDeleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
const count = await repo.deleteExpired(new Date());
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/modules/ai-runtime/ai-job-snapshot.repository.ts
Normal file
46
src/modules/ai-runtime/ai-job-snapshot.repository.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-02-05: 不可变 Job 输入快照 Repository
|
||||||
|
*
|
||||||
|
* 约束:
|
||||||
|
* - 只允许 create + findByJobId
|
||||||
|
* - 禁止 update / upsert / 覆盖已有 Snapshot
|
||||||
|
* - Snapshot 内容必须在应用层通过安全扫描后方可写入
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiJobSnapshotRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建快照。每个 Job 最多一个 Snapshot(jobId UNIQUE)。
|
||||||
|
* 若已存在则抛出 Prisma 唯一约束错误,调用方应处理。
|
||||||
|
*/
|
||||||
|
async create(data: {
|
||||||
|
jobId: string;
|
||||||
|
jobType: string;
|
||||||
|
schemaVersion: string;
|
||||||
|
redactionVersion?: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
contentHash?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}) {
|
||||||
|
return this.prisma.aiJobSnapshot.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 jobId 查询快照 */
|
||||||
|
async findByJobId(jobId: string) {
|
||||||
|
return this.prisma.aiJobSnapshot.findUnique({
|
||||||
|
where: { jobId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除过期的快照(由 cleanup service 调用) */
|
||||||
|
async deleteExpired(before: Date) {
|
||||||
|
const result = await this.prisma.aiJobSnapshot.deleteMany({
|
||||||
|
where: { expiresAt: { lt: before } },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user