diff --git a/prisma/migrations/20260620000000_m_ai_02_03_identity_routing/migration.sql b/prisma/migrations/20260620000000_m_ai_02_03_identity_routing/migration.sql new file mode 100644 index 0000000..3eb47d3 --- /dev/null +++ b/prisma/migrations/20260620000000_m_ai_02_03_identity_routing/migration.sql @@ -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; diff --git a/prisma/migrations/20260620000001_m_ai_02_04_lifecycle_execution/migration.sql b/prisma/migrations/20260620000001_m_ai_02_04_lifecycle_execution/migration.sql new file mode 100644 index 0000000..2f3c208 --- /dev/null +++ b/prisma/migrations/20260620000001_m_ai_02_04_lifecycle_execution/migration.sql @@ -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`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 164c372..b10b069 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,7 +40,7 @@ model User { activeRecallQuestions ActiveRecallQuestion[] quizAttempts QuizAttempt[] activeRecallAnswers ActiveRecallAnswer[] - aiAnalysisJobs AiAnalysisJob[] + aiJobs AiJob[] aiAnalysisResults AiAnalysisResult[] focusItems FocusItem[] reviewCards ReviewCard[] @@ -564,27 +564,114 @@ model ActiveRecallAnswer { @@index([sessionId]) } -model AiAnalysisJob { +model AiJob { id String @id @default(cuid()) userId String sessionId String? answerId String? - jobType String @db.VarChar(32) - status String @default("pending") @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) + // ── 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) - errorMessage String? @db.Text queuedAt DateTime? startedAt DateTime? - completedAt DateTime? + finishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt 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([status]) @@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 { @@ -604,7 +691,7 @@ model AiAnalysisResult { updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) - job AiAnalysisJob @relation(fields: [jobId], references: [id]) + job AiJob @relation(fields: [jobId], references: [id]) @@index([userId]) @@index([jobId]) @@ -766,27 +853,37 @@ model Feedback { } model AiUsageLog { - id String @id @default(cuid()) - userId String - feature String @db.VarChar(64) - provider String @db.VarChar(32) - model String @db.VarChar(100) - tier String @db.VarChar(32) - promptKey String @db.VarChar(128) - promptVersion String @db.VarChar(32) - inputTokens Int @default(0) - outputTokens Int @default(0) - estimatedCost Float @default(0) - latencyMs Int @default(0) - success Boolean @default(true) - errorMessage String? @db.VarChar(500) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + feature String @db.VarChar(64) + provider String @db.VarChar(32) + model String @db.VarChar(100) + tier String @db.VarChar(32) + promptKey String @db.VarChar(128) + promptVersion String @db.VarChar(32) + inputTokens Int @default(0) + outputTokens Int @default(0) + estimatedCost Float @default(0) + latencyMs Int @default(0) + success Boolean @default(true) + 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()) - 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([feature]) @@index([createdAt]) + @@index([jobId, attemptNo]) + @@index([provider, model, createdAt]) + @@index([errorCode, createdAt]) } model ModelRoute { @@ -2218,3 +2315,28 @@ model AiArtifactFeedback { @@index([userId]) @@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]) +}