fix(M-AI-07): stabilize Quiz generation request idempotency
Replace Date.now() fallback with deterministic content hash + time window. Idempotency strategy: Priority 1: client-provided idempotencyKey → direct use Priority 2: SHA-256(normalized params) + 10min window → retry-safe Active re-generation: client provides new idempotencyKey idempotencyKey format: quiz-generation:<contentHash24>:w<windowTs> Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
452144d9c0
commit
ec2379eaf3
159
src/modules/ai-runtime/quiz-execution-router.spec.ts
Normal file
159
src/modules/ai-runtime/quiz-execution-router.spec.ts
Normal file
@ -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<string>();
|
||||
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:<sha256-24chars>:w<window>
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<string, unknown>,
|
||||
});
|
||||
|
||||
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<boolean> {
|
||||
try {
|
||||
return await this.featureFlag.isEnabled(FLAG_NAME, userId);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user