diff --git a/src/modules/ai-runtime/quiz-execution-router.spec.ts b/src/modules/ai-runtime/quiz-execution-router.spec.ts new file mode 100644 index 0000000..4bfa00c --- /dev/null +++ b/src/modules/ai-runtime/quiz-execution-router.spec.ts @@ -0,0 +1,159 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { QuizExecutionRouter } from './quiz-execution-router'; +import { FeatureFlagService } from '../config/feature-flag.service'; +import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder'; +import { AiJobCreationService } from '../ai-job/ai-job-creation.service'; +import { UserAiService } from './user-ai.service'; +import * as crypto from 'crypto'; + +function dto(overrides?: any) { + return { + jobType: 'quiz_generation', + targetType: 'knowledge_base', + targetId: 'kb-001', + questionCount: 3, + questionTypes: ['choice', 'judge'], + difficultyLevel: 'medium', + ...overrides, + }; +} + +describe('QuizExecutionRouter', () => { + let router: QuizExecutionRouter; + let mockFlag: any; + let mockSnapshot: any; + let mockCreation: any; + let mockLegacy: any; + + beforeEach(async () => { + mockFlag = { isEnabled: jest.fn().mockResolvedValue(true) }; + mockSnapshot = { + build: jest.fn().mockResolvedValue({ + schemaVersion: 'v1', + snapshot: { userId: 'u1', targetId: 'kb-001', questionCount: 3 }, + }), + }; + mockCreation = { + createJob: jest.fn().mockImplementation(async (input: any) => ({ + id: 'job-' + Math.random().toString(36).slice(2, 8), + createdAt: new Date(), + ...input, + })), + }; + mockLegacy = { createAnalysisJob: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + QuizExecutionRouter, + { provide: FeatureFlagService, useValue: mockFlag }, + { provide: QuizGenerationSnapshotBuilder, useValue: mockSnapshot }, + { provide: AiJobCreationService, useValue: mockCreation }, + { provide: UserAiService, useValue: mockLegacy }, + ], + }).compile(); + + router = module.get(QuizExecutionRouter); + jest.spyOn(require('@nestjs/common').Logger.prototype, 'log').mockImplementation(() => {}); + jest.spyOn(require('@nestjs/common').Logger.prototype, 'warn').mockImplementation(() => {}); + }); + + describe('幂等:显式 idempotencyKey', () => { + it('相同 idempotencyKey → 相同 jobId', async () => { + const key = 'my-stable-key-001'; + mockCreation.createJob.mockResolvedValue({ id: 'job-stable', createdAt: new Date() }); + + const r1 = await router.generateQuiz('u1', dto({ idempotencyKey: key })); + const r2 = await router.generateQuiz('u1', dto({ idempotencyKey: key })); + + // Same idempotencyKey passed to creation service + const calls = mockCreation.createJob.mock.calls; + expect(calls[0][0].idempotencyKey).toBe(`quiz-generation:${key}`); + expect(calls[1][0].idempotencyKey).toBe(`quiz-generation:${key}`); + }); + }); + + describe('幂等:无 idempotencyKey(内容哈希 + 窗口)', () => { + it('相同参数 → 相同内容哈希(窗口内)', async () => { + const r1 = await router.generateQuiz('u1', dto()); + const r2 = await router.generateQuiz('u1', dto()); + + // Same content → same idempotencyKey (same window) + const calls = mockCreation.createJob.mock.calls; + expect(calls[0][0].idempotencyKey).toBe(calls[1][0].idempotencyKey); + }); + + it('不同参数 → 不同内容哈希', async () => { + const r1 = await router.generateQuiz('u1', dto({ questionCount: 3 })); + const r2 = await router.generateQuiz('u1', dto({ questionCount: 5 })); + + const calls = mockCreation.createJob.mock.calls; + // Different questionCount → different hash + expect(calls[0][0].idempotencyKey).not.toBe(calls[1][0].idempotencyKey); + }); + + it('questionTypes 排序后相同 → 相同哈希', async () => { + // Unsorted input should normalize + await router.generateQuiz('u1', dto({ questionTypes: ['judge', 'choice'] })); + await router.generateQuiz('u1', dto({ questionTypes: ['choice', 'judge'] })); + + const calls = mockCreation.createJob.mock.calls; + expect(calls[0][0].idempotencyKey).toBe(calls[1][0].idempotencyKey); + }); + }); + + describe('幂等:不包含 Date.now / random', () => { + it('idempotencyKey 不含随机值格式', async () => { + // Run 5 times with same params + const keys = new Set(); + for (let i = 0; i < 5; i++) { + const result = await router.generateQuiz('u1', dto()); + const key = mockCreation.createJob.mock.calls[i][0].idempotencyKey; + keys.add(key); + } + // Same params within same window → all identical keys + expect(keys.size).toBe(1); + + const theKey = [...keys][0]; + // Must match quiz-generation::w + expect(theKey).toMatch(/^quiz-generation:[0-9a-f]{24}:w\d+$/); + }); + }); + + describe('主动重新生成', () => { + it('新 idempotencyKey → 新 Job', async () => { + const r1 = await router.generateQuiz('u1', dto({ idempotencyKey: 'gen-1' })); + const r2 = await router.generateQuiz('u1', dto({ idempotencyKey: 'gen-2' })); + + const calls = mockCreation.createJob.mock.calls; + expect(calls[0][0].idempotencyKey).toBe('quiz-generation:gen-1'); + expect(calls[1][0].idempotencyKey).toBe('quiz-generation:gen-2'); + expect(calls[0][0].idempotencyKey).not.toBe(calls[1][0].idempotencyKey); + }); + }); + + describe('Legacy 兼容', () => { + it('legacy 模式不经过 Unified', async () => { + mockFlag.isEnabled.mockResolvedValue(false); + mockLegacy.createAnalysisJob.mockResolvedValue({ jobId: 'legacy-1' }); + + const result = await router.generateQuiz('u1', dto()); + + expect(result).toEqual({ jobId: 'legacy-1' }); + expect(mockCreation.createJob).not.toHaveBeenCalled(); + }); + }); + + describe('contentKey 确定性', () => { + it('submissionId 可由 contentKey 验证', async () => { + // Build expected key manually + const types = ['choice', 'judge'].sort().join(','); + const normalized = ['knowledge_base', 'kb-001', '3', types, 'medium'].join('|'); + const expectedHash = crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 24); + + await router.generateQuiz('u1', dto()); + + const key = mockCreation.createJob.mock.calls[0][0].idempotencyKey; + expect(key).toContain(expectedHash); + }); + }); +}); diff --git a/src/modules/ai-runtime/quiz-execution-router.ts b/src/modules/ai-runtime/quiz-execution-router.ts index ed04423..15fcd42 100644 --- a/src/modules/ai-runtime/quiz-execution-router.ts +++ b/src/modules/ai-runtime/quiz-execution-router.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; import { FeatureFlagService } from '../config/feature-flag.service'; import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder'; import type { QuizGenerationSnapshotInput } from '../ai-job/quiz-generation-snapshot-builder'; @@ -7,15 +8,23 @@ import { UserAiService } from './user-ai.service'; import type { CreateAnalysisJobDto } from './user-ai.dto'; /** - * M-AI-07-05: Quiz Execution Router + * M-AI-07-05 / M-AI-07-CLEANUP: Quiz Execution Router * * 根据 QUIZ_GENERATION_ENGINE_MODE Feature Flag 路由 Quiz 生成请求。 * - 'legacy' → UserAiService.createAnalysisJob() * - 'unified' → SnapshotBuilder → AiJobCreationService + * + * 幂等策略(M-AI-07-CLEANUP): + * Priority 1: 客户端显式 idempotencyKey → 直接使用 + * Priority 2: 规范化内容哈希 + 10min 窗口 → 兼容旧客户端重试 + * 主动重新生成:客户端提供新 idempotencyKey */ const FLAG_NAME = 'QUIZ_GENERATION_ENGINE_MODE'; +/** 短窗口去重窗口(毫秒),仅用于旧客户端兼容 */ +const IDEMPOTENCY_WINDOW_MS = 10 * 60 * 1000; // 10 minutes + @Injectable() export class QuizExecutionRouter { private readonly logger = new Logger(QuizExecutionRouter.name); @@ -28,7 +37,6 @@ export class QuizExecutionRouter { ) {} async generateQuiz(userId: string, dto: CreateAnalysisJobDto) { - // 1. 判断模式 const useUnified = await this.shouldUseUnified(userId); if (!useUnified) { @@ -36,17 +44,34 @@ export class QuizExecutionRouter { return this.legacyService.createAnalysisJob(userId, dto); } - // ═════════════════════════════════════════════════════════ - // Unified 路径 - // ═════════════════════════════════════════════════════════ - this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=unified for userId=${userId}`); - // 2. 确定 submissionId(幂等键来源) - const submissionId = dto.idempotencyKey ?? - `${dto.targetType}:${dto.targetId}:${Date.now().toString(36)}`; + // ═════════════════════════════════════════════════════════ + // 幂等标识解析(M-AI-07-CLEANUP) + // ═════════════════════════════════════════════════════════ + + let effectiveId: string; + let idSource: 'client' | 'compatibility_window'; + + if (dto.idempotencyKey) { + // Priority 1: 客户端显式操作标识 + effectiveId = dto.idempotencyKey; + idSource = 'client'; + } else { + // Priority 2: 规范化内容哈希 + 短窗口(兼容旧客户端重试) + const contentKey = this.buildContentKey(dto); + const windowTs = Math.floor(Date.now() / IDEMPOTENCY_WINDOW_MS); + effectiveId = `${contentKey}:w${windowTs}`; + idSource = 'compatibility_window'; + } + + const submissionId = effectiveId; + const idempotencyKey = `quiz-generation:${effectiveId}`; + + // ═════════════════════════════════════════════════════════ + // Snapshot + Job 创建 + // ═════════════════════════════════════════════════════════ - // 3. 构建 Snapshot const snapshotInput: QuizGenerationSnapshotInput = { userId, targetType: dto.targetType, @@ -60,10 +85,6 @@ export class QuizExecutionRouter { const snapshot = await this.snapshotBuilder.build(snapshotInput); - // 4. 幂等键 - const idempotencyKey = dto.idempotencyKey ?? `quiz-generation:${submissionId}`; - - // 5. 通过 AiJobCreationService 创建 Job(原子:Job + Snapshot + Outbox) const job = await this.creationService.createJob({ userId, jobType: 'quiz_generation', @@ -74,22 +95,45 @@ export class QuizExecutionRouter { retrySnapshotContent: snapshot as unknown as Record, }); + const isIdempotentHit = job.createdAt?.getTime?.() !== undefined && + (Date.now() - new Date(job.createdAt).getTime()) > 1000; + this.logger.log( `Quiz Unified: jobId=${job.id} userId=${userId} ` + + `idSource=${idSource} idempotencyHit=${isIdempotentHit} ` + `targetType=${dto.targetType} targetId=${dto.targetId} ` + `questionCount=${snapshot.snapshot.questionCount}`, ); - // 6. 返回兼容响应 return { jobId: job.id, status: 'queued', engineMode: 'unified' as const, lifecycleStatus: 'queued', - planId: undefined, // Unified 不创建 QuestionGenerationPlan + planId: undefined, }; } + // ── Private ── + + /** + * 构建确定性内容键(SHA-256 前 24 字符)。 + * + * 规范化参数:targetType, targetId, questionCount, questionTypes(sorted), difficultyLevel。 + * 不包含:时间戳、随机值、完整学习资料、PII。 + */ + private buildContentKey(dto: CreateAnalysisJobDto): string { + const types = (dto.questionTypes ?? []).slice().sort().join(','); + const normalized = [ + dto.targetType, + dto.targetId, + String(dto.questionCount ?? 5), + types || 'choice,fill,judge', + dto.difficultyLevel ?? 'medium', + ].join('|'); + return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 24); + } + private async shouldUseUnified(userId: string): Promise { try { return await this.featureFlag.isEnabled(FLAG_NAME, userId);