docs: ADR-003 冻结统一 Job Engine、Registry、Outbox 与状态机
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 31s
Deploy API Server / current-integration (push) Successful in 28s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m3s

冻结 M-AI-03 完整实现契约:
- 状态机:5 状态、6 允许迁移、6 非法迁移、CAS 并发、Heartbeat/Reaper
- Job Definition Registry:TypeScript 接口冻结、注册校验、禁止 switch(jobType)
- 队列:ai-interactive/ai-background/ai-analysis 配置、Payload、Worker 边界
- Outbox:ai.job.enqueue 事件、CAS 先于 queue.add 的领取算法、崩溃窗口分析
- Execution Engine:PREPARE/RESOLVE/EXECUTE/PROJECT/COMPLETE 五阶段管线
- Projector:事务边界、AiJobArtifact 幂等、失败回滚
- API:4 端点、JobStatusResponse 接口、6 公开错误码
- 安全:Credential/Redis/日志/Snapshot/Outbox payload 约束

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-20 17:06:13 +08:00
parent 8e5108751c
commit 608129be4e

View File

@ -0,0 +1,782 @@
# ADR-003统一 AI Job Engine、Registry、Outbox 与状态机
## 状态
已提出2026-06-20
## 背景
M-AI-03-01 审计(`docs/architecture/m-ai-03-current-execution-audit.md`)确认:
- 存在两套 Job 系统:**AiJob**BullMQ + `AiAnalysisWorker`2 种 jobType**AiRuntimeJob**REST Poll + 外部 heavy-runtime5 种 jobType0 行生产数据)
- M-AI-02 已在 `AiAnalysisJob` 物理表上完成 Schema Expand19 个新字段就绪Outbox/Artifact/Snapshot 表已创建
- **3 个 BullMQ 队列**notification、domain-events、audit-logs的 Consumer 已注册但无活跃 Producer
- **OutboxRepository** 已完整实现但零调用方,无 Dispatcher
- **queueName 不一致**AiAnalysisRepository 写入 `queueName: 'ai-interactive'`,实际 BullMQ 路由到 `ai-analysis`
- **STATUS_TO_LIFECYCLE 映射缺口**:仅 4 个状态,缺少 `cancel_requested` / `cancelled`
M-AI-03 的目标是在 M-AI-02 Schema Expand 之上,纯代码层实现统一 Job Engine、Registry、Outbox Dispatcher 与 Projector——**无 Prisma Migration**、**不迁移真实业务 Job**、**不修改已有 Worker/队列**。
---
## 决策
### 1. 状态机
#### 1.1 完整状态定义
```
┌──────────┐
│ queued │ ← 唯一初始状态AiJobLifecycleRepository.createJob
└────┬─────┘
│ lockJob (CAS)
┌────▼─────┐
│ running │
└────┬─────┘
┌────────┼──────────┐
│ │ │
┌──────▼──┐ ┌──▼───┐ ┌───▼──────┐
│succeeded│ │failed│ │cancelled │ ← 终态
└─────────┘ └──────┘ └──────────┘
```
| 状态 | 说明 | 终态 | 进入条件 |
|------|------|------|---------|
| `queued` | Job 已创建并入队,等待 Worker 领取 | 否 | `AiJobLifecycleRepository.createJob()` |
| `running` | Worker 已锁定并开始执行 | 否 | `AiJobLifecycleRepository.lockJob()` CAS 成功 |
| `succeeded` | 执行成功Projector 已提交结果 | 是 | `AiJobLifecycleRepository.markSucceeded()` |
| `failed` | 执行失败且重试耗尽 | 是 | `AiJobLifecycleRepository.markFailed()` |
| `cancelled` | 用户/系统取消 | 是 | `AiJobLifecycleRepository.markCancelled()` |
#### 1.2 允许迁移
```
queued → running (lockJob)
queued → cancelled (用户取消,仅 queued 状态允许)
running → succeeded (submitResult)
running → failed (submitFailureretryCount >= maxRetryCount)
running → cancelled (cancelRequestedAt 已设置heartbeat 检测)
```
#### 1.3 非法迁移
以下迁移在任何情况下都不允许:
```
queued → succeeded (必须先 lockJob → running)
queued → failed (必须先 lockJob → running)
succeeded → * (终态,不可变)
failed → * (终态,不可变;需重试应创建新 Job)
cancelled → * (终态,不可变)
running → queued (不可回退;需重试应通过 retryCount 机制)
```
#### 1.4 唯一写入入口
**所有** `lifecycleStatus` 的写入必须通过 `AiJobLifecycleRepository` 的以下方法:
```typescript
// AiJobLifecycleRepository — 唯一写入 lifeCycleStatus 的入口
export class AiJobLifecycleRepository {
createJob(tx: Prisma.TransactionClient, input: CreateJobInput): Promise<AiJob>;
lockJob(jobId: string, instanceId: string): Promise<AiJob>; // CAS: queued → running
markSucceeded(jobId: string): Promise<AiJob>;
markFailed(jobId: string, error: JobErrorInfo): Promise<AiJob>;
markCancelled(jobId: string): Promise<AiJob>;
// 只读
findByLifecycleStatus(status: LifecycleStatus[], opts?: { queueName?: string; limit?: number }): Promise<AiJob[]>;
findExpiredLocks(thresholdMs: number): Promise<AiJob[]>;
}
```
**禁止**:业务代码直接调用 `prisma.aiJob.update({ where: { id }, data: { lifecycleStatus: '...' } })``this.repository.updateJobStatus()` 写入 `lifecycleStatus`
#### 1.5 Legacy `status` Shadow Write
过渡期状态映射M-AI-02-10 已有,本 ADR 扩展):
```typescript
const STATUS_TO_LIFECYCLE: Record<string, string> = {
pending: 'queued',
processing: 'running',
completed: 'succeeded',
failed: 'failed',
cancelled: 'cancelled', // 新增 — 修正 #284 发现的映射缺口
cancel_requested: 'cancelled', // 新增 — 统一映射
};
```
`AiJobLifecycleRepository` 变更 `lifecycleStatus` 时,若 legacy `status` 列存在同步写入映射值Shadow Write。M-AI-05 完成后删除 `status` 列。
#### 1.6 并发状态更新策略
`lockJob` 使用 **MySQL 乐观锁**CAS updateMany
```typescript
async lockJob(jobId: string, instanceId: string): Promise<AiJob> {
const now = new Date();
const lockUntil = new Date(now.getTime() + 60_000); // 60s TTL
const result = await this.prisma.aiJob.updateMany({
where: {
id: jobId,
lifecycleStatus: 'queued',
},
data: {
lifecycleStatus: 'running',
lockedBy: instanceId,
lockedAt: now,
lockUntil,
startedAt: now,
},
});
if (result.count === 0) {
throw new JobLockConflictError(jobId);
}
return this.prisma.aiJob.findUniqueOrThrow({ where: { id: jobId } });
}
```
#### 1.7 Heartbeat 与过期锁收割
| 机制 | 实现 | 频率 |
|------|------|------|
| Heartbeat | `AiJobLifecycleRepository.extendLock(jobId, instanceId)``UPDATE ... SET lockUntil = now() + 60s WHERE lifecycleStatus = 'running' AND lockedBy = instanceId` | 每 30s |
| 过期锁收割 | `AiJobLifecycleRepository.findExpiredLocks(90_000)` → 锁超时 90s60s TTL + 30s 宽限期)未续约 → `markFailed`,触发 BullMQ retry | 每 30s |
---
### 2. Job Definition Registry
#### 2.1 TypeScript 接口(冻结)
```typescript
// ── Job Definition ──
interface JobDefinition {
/** 全局唯一 jobType与 AiJob.jobType 对应 */
jobType: string;
/** 元数据 */
metadata: {
/** 人类可读标签 */
label: string;
/** 描述 */
description: string;
/** 所属领域 */
domain: 'analysis' | 'generation' | 'import' | 'maintenance';
/** 版本 */
version: string;
};
/** 路由策略 */
queue: {
/** 目标队列名 */
queueName: 'ai-interactive' | 'ai-background';
/** 优先级 0=最高, 100=最低 */
defaultPriority: number;
};
/** 执行策略 */
execution: {
/** 超时(毫秒) */
timeoutMs: number;
/** 最大重试次数(不含首次) */
maxRetries: number;
/** BullMQ 重试退避 */
retryBackoff: { type: 'exponential'; delay: number };
/** 是否支持取消 */
cancellable: boolean;
/** AbortSignal 超时后行为 */
abortStrategy: 'fail' | 'retry';
};
/** 输入 Schema 版本(对应 SnapshotBuilder 产出) */
input: {
schemaVersion: string;
};
/** 输出 Schema 版本(对应 AI Provider 产出 JSON Schema */
output: {
schemaVersion: string;
/** Zod schema 引用,用于输出校验 */
validator?: string;
};
/** 凭据模式 */
credential: {
/** 支持的凭据模式 */
allowedModes: ('platform_key' | 'user_deepseek_key')[];
/** 默认模式 */
defaultMode: 'platform_key' | 'user_deepseek_key';
};
/** Projector 引用 */
projector?: string; // 对应 ResultProjector 注册 key
/** 安全约束 */
security: {
/** 是否需要内容安全检查 */
contentSafetyCheck: boolean;
/** 输出是否需要脱敏后存储 */
outputRedaction: boolean;
};
}
```
#### 2.2 Registry
```typescript
// JobDefinitionRegistry — 替代未来可能出现的巨型 switch(jobType)
@Injectable()
export class JobDefinitionRegistry {
private definitions = new Map<string, JobDefinition>();
register(def: JobDefinition): void {
if (this.definitions.has(def.jobType)) {
throw new DuplicateJobDefinitionError(def.jobType);
}
// 启动时校验
this.validate(def);
this.definitions.set(def.jobType, def);
}
get(jobType: string): JobDefinition {
const def = this.definitions.get(jobType);
if (!def) throw new UnknownJobTypeError(jobType);
return def;
}
getAll(): JobDefinition[] {
return Array.from(this.definitions.values());
}
private validate(def: JobDefinition): void {
if (!def.jobType.match(/^[a-z][a-z0-9_]{1,63}$/)) {
throw new InvalidJobTypeError(def.jobType);
}
if (!['ai-interactive', 'ai-background'].includes(def.queue.queueName)) {
throw new InvalidQueueError(def.queue.queueName);
}
if (def.execution.timeoutMs < 1000 || def.execution.timeoutMs > 600_000) {
throw new InvalidTimeoutError(def.execution.timeoutMs);
}
// ... 其他校验
}
}
```
#### 2.3 注册生命周期
```typescript
// 在 AiJobModule 的 onModuleInit 中注册
@Module({ /* ... */ })
export class AiJobModule implements OnModuleInit {
constructor(private readonly registry: JobDefinitionRegistry) {}
onModuleInit() {
// M-AI-04 才注册 active-recall / feynman-evaluation
// M-AI-06 才注册 quiz_generation / flashcard_generation
// M-AI-07 才注册 learning_state_analysis / weak_point_analysis / next_action_planning
// M-AI-03 仅注册 Synthetic Definition测试用
this.registry.register(SYNTHETIC_JOB_DEFINITION);
}
}
```
---
### 3. 队列架构
#### 3.1 队列配置
| 队列名 | 用途 | Worker 进程 | 默认 concurrency | lockDuration | attempts |
|--------|------|------------|-----------------|-------------|----------|
| `ai-interactive` | 新统一 Engine — 交互式 Job用户等待结果 | `WorkerModule` 新增 | 2 | 30s | 3 |
| `ai-background` | 新统一 Engine — 后台 Job用户不等待 | `WorkerModule` 新增 | 3 | 60s | 3 |
| `ai-analysis` | 旧系统 A — active-recall + feynman-evaluation | `WorkerModule`(已有) | 1 | 30s | 3 |
**约束**
- `ai-interactive` / `ai-background` 仅在 `WorkerModule` 中注册 Processor
- 旧 `ai-analysis` 队列不修改、不删除
- `document-import``notification``domain-events``audit-logs``file-cleanup` 五个队列不在 M-AI-03 范围
#### 3.2 Queue Payload冻结
```typescript
// ── BullMQ Job Payload ──
interface AiJobPayload {
/** AiJob 数据库主键 */
jobId: string;
/** jobType — Worker 据此 lookup JobDefinition */
jobType: string;
/** 幂等 Key与 Outbox dedupeKey 相同) */
idempotencyKey: string;
/** 创建时间戳 */
createdAt: string;
}
// BullMQ jobId 格式: "{queueName}:{jobType}:{idempotencyKey}"
// 例如: "ai-interactive:active-recall:user123_20260620_001"
```
#### 3.3 Worker 进程注册边界
```
WorkerModule 新增:
├── AiInteractiveJobWorker @Processor('ai-interactive')
│ └── AiJobExecutionEngine.execute()
└── AiBackgroundJobWorker @Processor('ai-background')
└── AiJobExecutionEngine.execute()
WorkerModule 已有(不修改):
├── AiAnalysisWorker @Processor('ai-analysis')
├── DocumentImportWorker @Processor('document-import')
├── NotificationWorker @Processor('notification')
├── AuditLogProcessor @Processor('audit-logs')
└── FileCleanupProcessor @Processor('file-cleanup')
```
---
### 4. Outbox
#### 4.1 事件类型
| eventType | 触发时机 | dedupeKey 组成 | aggregateType |
|-----------|---------|---------------|---------------|
| `ai.job.enqueue` | 事务内创建 Job 后 | `{jobType}:{userId}:{idempotencyKey}` | `AiJob` |
**未来扩展**(不在 M-AI-03 范围):`ai.job.completed``ai.job.failed`
#### 4.2 Dedupe 策略
`dedupeKey` 在 Outbox 表上有 **UNIQUE 约束**`prisma/schema.prisma:2323`)。重复插入 → 数据库层拒绝,调用方视为幂等成功。
#### 4.3 领取算法(修正 #284 发现的并发竞态)
```
Dispatcher.cycle():
1. findDispatchable(N) → 读取 status='pending' AND availableAt <= now()
2. FOR EACH event:
a. locked = markProcessing(eventId, instanceId) // CAS: pending → processing
b. IF locked IS NULL → SKIP其他 Dispatcher 已抢走)
c. success = queue.add(event.payload) // 投递到 BullMQ
d. IF success → markPublished(eventId)
ELSE → markFailed(eventId, 'DISPATCH_FAILED')
```
**核心修复**:先 CAS 抢锁(步骤 2a锁成功**后**才投递(步骤 2c。UT (#284 审计发现的CAS 失败时跳过而非重试其他事件。修复了 `queue.add()` 先于 `markProcessing()` 导致的重复投递。
#### 4.4 锁超时与重试
| 参数 | 值 | 说明 |
|------|-----|------|
| `markProcessing` CAS 条件 | `status = 'pending'` | 仅 pending 事件可抢锁 |
| `lockedAt` TTL | 60s | Dispatcher 崩溃后事件锁有效时间 |
| `releaseExpiredLocks` 间隔 | 30s | 收割过期 processing → pending |
| 最大 `attemptCount` | 3 | 超过后 `status = 'dead'`(新增终态,区别于 `failed` |
| 未知 eventType | 记录 warning 日志,标记 `published`(避免阻塞队列),不投递 | — |
#### 4.5 崩溃窗口处理
Dispatcher 或 BullMQ Redis 在 `markProcessing()` 成功但 `queue.add()` 尚未执行时崩溃:
- `lockedAt` 超时60s`releaseExpiredLocks()` 重置为 `pending` → 下一个周期重新领取
- 潜在重复投递窗口:**60s**
- 减轻措施BullMQ `jobId = "{queueName}:{jobType}:{idempotencyKey}"` 确保相同 dedupeKey 的入队幂等BullMQ 同 jobId 重复 add 视为 no-op
---
### 5. Execution Engine
#### 5.1 执行阶段(管线)
```
Job (BullMQ)
├─ 1. PREPARE
│ ├─ lookup JobDefinition via Registry
│ ├─ load AiJob from DB
│ ├─ verify lifecycleStatus = 'queued'
│ └─ lockJob (CAS queued → running)
├─ 2. RESOLVE
│ ├─ resolve snapshot (SnapshotBuilder)
│ ├─ resolve credential (credentialMode / credentialId)
│ ├─ resolve prompt (PromptTemplateService)
│ └─ resolve provider (ModelRouter → AiProvider)
├─ 3. EXECUTE
│ ├─ AbortSignal (timeout from JobDefinition.execution.timeoutMs)
│ ├─ content safety check (if security.contentSafetyCheck)
│ ├─ AiGatewayService.generate(request)
│ ├─ parse & validate output (outputSchema)
│ └─ compute outputHash
├─ 4. PROJECT
│ ├─ begin Prisma transaction
│ ├─ ResultProjector.project(output) → Artifact(s)
│ ├─ AiJobLifecycleRepository.markSucceeded()
│ ├─ OutboxRepository.createInTransaction(tx, ai.job.completed) // 未来
│ └─ commit transaction
└─ 5. COMPLETE
├─ usage logging (ModelInvocationLog)
├─ publish domain event (sync EventBus)
└─ return
```
#### 5.2 错误分类
| 错误类别 | 错误码前缀 | 可重试 | 行为 |
|---------|-----------|--------|------|
| `PROVIDER_*` | 外部 AI Provider 错误超时、5xx、限流 | ✅ | retry with backoff |
| `VALIDATION_*` | 输出 Schema 校验失败 | ✅ | retry重试时调整 prompt |
| `CONTENT_SAFETY_*` | 内容安全拦截 | ❌ | 标记 failed不重试 |
| `TIMEOUT` | Job 超时 | ✅(如果 abortStrategy = 'retry' | 根据 Definition 决定 |
| `CREDENTIAL_*` | 凭据无效/过期 | ❌ | 标记 failed用户通知 |
| `CANCELLED` | 用户/系统取消 | ❌ | 标记 cancelled |
| `INTERNAL_*` | 内部错误DB、Redis、代码异常 | ✅(最多 1 次) | 重试 1 次后 failed |
#### 5.3 Timeout 与 AbortSignal
```typescript
// Execution Engine 内部
const def = this.registry.get(jobType);
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
this.logger.warn(`Job ${jobId} timed out after ${def.execution.timeoutMs}ms`);
}, def.execution.timeoutMs);
try {
const output = await this.aiGateway.generate(request, controller.signal);
clearTimeout(timeoutId);
// ...
} catch (err) {
clearTimeout(timeoutId);
if (controller.signal.aborted) {
throw new JobTimeoutError(jobId, def.execution.timeoutMs);
}
throw err;
}
```
#### 5.4 取消
```typescript
// 用户取消流程
// 1. UserAiController.cancelJob() → AiJobLifecycleRepository.markCancelled(jobId)
// - queued → cancelled直接
// - running → 设置 cancelRequestedAtHeartbeat 检测后 markCancelled
// - succeeded/failed/cancelled → 409 ConflictException终态不可取消
// 2. Worker Heartbeat 检测
// Execution Engine 在每次 Provider 调用前后检查:
// if (job.cancelRequestedAt != null) throw new JobCancelledError(jobId);
```
#### 5.5 Retry
| 层级 | 机制 | 配置来源 |
|------|------|---------|
| BullMQ | attempts + backoff | `JobDefinition.execution.maxRetries` + `retryBackoff` |
| Execution Engine | 内部重试循环 | `JobDefinition.execution.maxRetries` |
| Outbox | attemptCount < 3 pending>= 3 → dead | OutboxRepository |
**约束**BullMQ retry 与 Engine 内部 retry 不叠加。Engine 内部错误不抛出(不触发 BullMQ retry而是在 Engine 内完成所有重试后,一次性 `markFailed``markSucceeded`
#### 5.6 ValidatedOutput 与 OutputHash
```
outputHash = SHA256(JSON.stringify(validatedOutput, Object.keys(validatedOutput).sort()))
```
- 存储在 `AiJob.outputHash`
- 用于去重(相同 input + outputHash → 幂等返回已有结果)
- 用于 Artifact 去重(相同 outputHash → 跳过 Projector
#### 5.7 Usage Logging
每次 AI Provider 调用后写入 `AiUsageLog`M-AI-02-08 已扩展 `jobId``attemptNo``credentialMode``errorCode` 字段),关联 `AiJob.id`
---
### 6. Result Projector
#### 6.1 接口(冻结)
```typescript
interface ResultProjector {
/** 全局唯一 key对应 JobDefinition.projector */
readonly key: string;
/**
* 在 Prisma Transaction 内执行投影。
* 如果 Artifact 已存在(幂等),返回已有引用。
*/
project(
tx: Prisma.TransactionClient,
job: AiJob,
validatedOutput: Record<string, any>,
): Promise<ProjectionResult>;
}
interface ProjectionResult {
/** 创建的 Artifact 引用列表 */
artifacts: Array<{
artifactType: string;
artifactId: string;
ordinal: number;
}>;
}
```
#### 6.2 事务边界
```
BEGIN TRANSACTION
1. ResultProjector.project(tx, job, validatedOutput)
→ 写入业务表QuizQuestion / Flashcard / AiLearningAnalysis / WeakPointCandidate / NextActionRecommendation 等)
→ 写入 AiJobArtifact (jobId, artifactType, artifactId)
2. AiJobLifecycleRepository.markSucceeded(tx, jobId)
3. (未来) OutboxRepository.createInTransaction(tx, { eventType: 'ai.job.completed', ... })
COMMIT
```
- **全部成功** → Commit
- **任何步骤失败** → RollbackJob 重新进入 retry 流程
- **Projector 执行中触发唯一约束冲突** → 视为幂等成功,跳过
#### 6.3 幂等保证
- `AiJobArtifact``@@unique([jobId, artifactType, artifactId])`
- 重复 Projection → DB UNIQUE 冲突 → catch → 返回已有 Artifact 引用
- `{{##duplicate}}` 标记在 Projector 日志中
#### 6.4 失败回滚
Projector 抛错 → Transaction Rollback → `JobFailedError` → Engine retry如果可重试或 markFailed
---
### 7. API 设计
#### 7.1 端点
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| `GET` | `/api/ai/jobs/:jobId` | 用户(自己的 Job/ Admin | Job 状态查询 |
| `POST` | `/api/ai/jobs/:jobId/cancel` | 用户(自己的 Job | 取消 Job |
| `GET` | `/admin-api/ai/jobs` | AdminSUPER_ADMIN | Job 列表查询(支持按 jobType/status/queueName/时间范围筛选) |
| `POST` | `/admin-api/ai/jobs/:jobId/retry` | AdminSUPER_ADMIN | 手动重试失败 Job创建新 Job复制 input |
#### 7.2 Job 状态响应(冻结)
```typescript
interface JobStatusResponse {
jobId: string;
jobType: string;
lifecycleStatus: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
queueName: string;
priority: number;
progress: number; // 0100
createdAt: string;
startedAt?: string;
finishedAt?: string;
errorCode?: string;
publicErrorMessage?: string; // 面向用户的错误,不含内部堆栈
result?: {
artifactCount: number;
artifacts: Array<{ artifactType: string; artifactId: string }>;
};
}
```
#### 7.3 公开错误码
| 错误码 | HTTP 状态 | 说明 |
|--------|----------|------|
| `JOB_NOT_FOUND` | 404 | Job 不存在或不属于当前用户 |
| `JOB_NOT_CANCELLABLE` | 409 | Job 处于终态,不可取消 |
| `JOB_ALREADY_CANCELLED` | 409 | Job 已被取消 |
| `TOO_MANY_JOBS` | 429 | 用户并发 Job 数超限 |
| `QUOTA_EXCEEDED` | 429 | 用户日/月额度用尽 |
| `PLATFORM_UNAVAILABLE` | 503 | 平台 AI 服务不可用 |
#### 7.4 Admin 公开错误
Admin 端点额外返回 `internalErrorMessage`(仅 SUPER_ADMIN 角色可见),包含内部堆栈信息。
---
### 8. 安全约束
#### 8.1 Credential
- 用户 Key`user_deepseek_key` mode通过 `CredentialEncryptionService` 加解密AiJob 表仅存储 `credentialId`FK → `UserModelCredential`
- 平台 Key`platform_key` mode从环境变量注入不在 DB 中存储
- Runtime Resolve 端点:仅验证 Runtime Service Token 后返回解密 Key
#### 8.2 Redis
- BullMQ 连接通过 Redis ACL 用户隔离
- Heartbeat Key`worker:heartbeat:{instanceId}`TTL 90s
- 取消信号:`job:cancel:{jobId}`TTL 5min
- 无敏感数据AiJob payload存储在 Redis
#### 8.3 日志
- `AiJob.payload`input不在日志中输出
- `validatedOutput` 不在日志中输出(仅 outputHash
- `publicErrorMessage` 不含 IP、路径、堆栈
- `internalErrorMessage` 仅 SUPER_ADMIN 可见
#### 8.4 Snapshot
- `AiJobSnapshot.content` 存储在 DBJSON 列),遵循 `redactionVersion` 脱敏规则
- `expiresAt` 过期后由 `SnapshotCleanupService` 清理
#### 8.5 Outbox Payload
- `OutboxEvent.payload` 仅包含 `{ jobId, jobType, queueName, priority }`
- 不含 `userId``credentialId`、snapshot 内容
---
### 9. 模块图
```
AiJobModule新增
├── AiJobService // 统一 Job 创建入口事务内AiJob + Outbox
├── AiJobLifecycleRepository // 唯一 lifecycleStatus 写入入口
├── JobDefinitionRegistry // JobType → Definition 注册表
├── AiJobExecutionEngine // 统一执行管线PREPARE/RESOLVE/EXECUTE/PROJECT/COMPLETE
├── OutboxDispatcher // 轮询 OutboxEvent → CAS 领取 → BullMQ 投递
├── ResultProjector // 投影器接口 + 内置实现(业务 Issue 注册)
├── AiInteractiveJobWorker // @Processor('ai-interactive')
└── AiBackgroundJobWorker // @Processor('ai-background')
依赖模块(已有,不修改):
├── AiGatewayService // AI Provider 调用
├── ModelRouter // Provider 路由
├── PromptTemplateService // Prompt 模板
├── AiCostCalculatorService // 成本计算
├── AiUsageLogService // 用量记录
├── ContentSafetyService // 内容安全
├── CredentialEncryptionService // 凭据加解密
├── SnapshotBuilderService // Snapshot 构建
├── QueueService // BullMQ 入队(仅 OutboxDispatcher 使用)
└── EventBusService // 领域事件sync publish
```
### 10. 时序图
```
User AiJobService AiJobLifecycleRepo OutboxRepo BullMQ Worker Engine AiGateway
│ POST /ai/jobs │ │ │ │ │ │
├─────────────────────>│ │ │ │ │ │
│ │ │ │ │ │ │
│ │─ BEGIN TX ────────> │ │ │ │
│ │─ createJob(tx) ──>│ │ │ │ │
│ │ │─INSERT AiJob─────│ │ │ │
│ │ │ (queued) │ │ │ │
│ │─ createInTx(tx) ──────────────────>│ │ │ │
│ │ │ INSERT Outbox │ │ │ │
│ │─ COMMIT ───────────────────────────────────────────────────────────────────────────────│
│ │ │ │ │ │ │
<── { jobId, queued } │ │ │ │ │ │
│ │ │ │ │ │ │
│ │ │ ╔═════════════════════════════╗ │
│ │ │ ║ OutboxDispatcher 异步 ║ │
│ │ │ ║ ║ │
│ │ │ ║ findDispatchable() ║ │
│ │ │ ║ markProcessing(CAS) ║ │
│ │ │ ║ queue.add('ai-interactive')─>│ │
│ │ │ ║ markPublished() ║ │
│ │ │ ╚═════════════════════════════╝ │
│ │ │ │ │ │ │
│ │ │ │ ai-interactive│ │ │
│ │ │ │<───────────────│ │ │
│ │ │ │ │─ process() ─>│ │
│ │ │ │ │ │ │
│ │ │ │ │ │─ 1. PREPARE │
│ │ │ │ │ │ lockJob(CAS) │
│ │ │ │ │ │ │
│ │ │ │ │ │─ 2. RESOLVE │
│ │ │ │ │ │ snapshot │
│ │ │ │ │ │ credential │
│ │ │ │ │ │ │
│ │ │ │ │ │─ 3. EXECUTE ──────────────────>│
│ │ │ │ │ │ │generate()
│ │ │ │ │ │<──────── ─ ─ ─ ─ ─│
│ │ │ │ │ │ validatedOutput │
│ │ │ │ │ │ │
│ │ │ │ │ │─ 4. PROJECT │
│ │ │ │ │ │ markSucceeded │
│ │ │ │ │ │ create Artifact │
│ │ │ │ │ │ │
│ │ │ │ │ │─ 5. COMPLETE │
│ │ │ │ │ │ usage log │
│ │ │ │ │ │ event │
```
---
### 11. 测试矩阵
| 测试层级 | 范围 | Issue |
|---------|------|-------|
| 单元测试 | `AiJobLifecycleRepository` — 状态迁移、非法迁移、CAS 并发 | #286 |
| 单元测试 | `JobDefinitionRegistry` — 注册、查重、校验 | #287 |
| 单元测试 | `OutboxDispatcher` — 领取算法、CAS 竞争、崩溃恢复 | #290 |
| 单元测试 | `AiJobExecutionEngine` — 正常执行、超时、取消、重试 | #291 |
| 单元测试 | `ResultProjector` — 事务提交、幂等、失败回滚 | #292 |
| 集成测试 | Synthetic Job Definition 端到端 | #294 |
| 集成测试 | `AiJobService` + `OutboxRepository` 事务原子性 | #289 |
| 集成测试 | Admin API 查询/取消/重试 | #293 |
---
### 12. 非目标M-AI-03 明确不包含)
1. **无 Prisma Migration** — 全部在 M-AI-02 Schema 基础上实现
2. **不迁移真实业务 Job**`ai-analysis` 队列的 active-recall 和 feynman-evaluation 继续走旧链路
3. **不创建通用公开创建接口**`AiJobService.createJob()` 为 internal-only外部通过具体业务 Service 调用
4. **不修改 AiRuntimeJob 表** — 不碰、不读、不写
5. **不修改已有 Worker**`AiAnalysisWorker``DocumentImportWorker` 等不受影响
6. **不引入新的第三方依赖**
7. **不替换 BullMQ** — 队列基础设施不变
---
## 影响
- 所有 M-AI-03 后续 Issue#286#295)必须引用本 ADR
- `ai-interactive` / `ai-background` 队列在 `QueueModule` 中注册
- `OutboxDispatcher``WorkerModule` 中注册为独立 Service非 BullMQ Processor
## 回滚
每个 Issue 独立回滚revert 对应 commit。本 ADR 无代码变更,无需回滚。
## 证据
- M-AI-03-01 审计:`docs/architecture/m-ai-03-current-execution-audit.md`
- M-AI-02 Schema 冻结:`docs/architecture/adr-002-ai-job-database-expand.md`
- M-AI-01 架构决策:`docs/architecture/adr-001-unified-ai-architecture.md`
- 当前 Prisma Schema`prisma/schema.prisma:568-677`AiJob、AiJobSnapshot、AiJobArtifact
- Outbox Repository`src/infrastructure/outbox/outbox.repository.ts:27-139`
- Queue Definitions`src/infrastructure/queue/queue-definitions.ts:94-101`
- Worker Module`src/worker.module.ts:1-82`