fix(M-AI-07): stabilize Quiz generation request idempotency
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 39s
Deploy API Server / current-integration (push) Successful in 30s
Deploy API Server / backward-compat (push) Successful in 1s
Deploy API Server / deploy (push) Successful in 1m1s

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:
wangdl 2026-06-22 21:19:20 +08:00
parent 452144d9c0
commit ec2379eaf3
2 changed files with 219 additions and 16 deletions

View 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);
});
});
});

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import * as crypto from 'crypto';
import { FeatureFlagService } from '../config/feature-flag.service'; import { FeatureFlagService } from '../config/feature-flag.service';
import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder'; import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder';
import type { QuizGenerationSnapshotInput } 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'; 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 * QUIZ_GENERATION_ENGINE_MODE Feature Flag Quiz
* - 'legacy' UserAiService.createAnalysisJob() * - 'legacy' UserAiService.createAnalysisJob()
* - 'unified' SnapshotBuilder AiJobCreationService * - 'unified' SnapshotBuilder AiJobCreationService
*
* M-AI-07-CLEANUP
* Priority 1: 客户端显式 idempotencyKey 使
* Priority 2: 规范化内容哈希 + 10min
* idempotencyKey
*/ */
const FLAG_NAME = 'QUIZ_GENERATION_ENGINE_MODE'; const FLAG_NAME = 'QUIZ_GENERATION_ENGINE_MODE';
/** 短窗口去重窗口(毫秒),仅用于旧客户端兼容 */
const IDEMPOTENCY_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
@Injectable() @Injectable()
export class QuizExecutionRouter { export class QuizExecutionRouter {
private readonly logger = new Logger(QuizExecutionRouter.name); private readonly logger = new Logger(QuizExecutionRouter.name);
@ -28,7 +37,6 @@ export class QuizExecutionRouter {
) {} ) {}
async generateQuiz(userId: string, dto: CreateAnalysisJobDto) { async generateQuiz(userId: string, dto: CreateAnalysisJobDto) {
// 1. 判断模式
const useUnified = await this.shouldUseUnified(userId); const useUnified = await this.shouldUseUnified(userId);
if (!useUnified) { if (!useUnified) {
@ -36,17 +44,34 @@ export class QuizExecutionRouter {
return this.legacyService.createAnalysisJob(userId, dto); return this.legacyService.createAnalysisJob(userId, dto);
} }
// ═════════════════════════════════════════════════════════
// Unified 路径
// ═════════════════════════════════════════════════════════
this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=unified for userId=${userId}`); this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=unified for userId=${userId}`);
// 2. 确定 submissionId幂等键来源 // ═════════════════════════════════════════════════════════
const submissionId = dto.idempotencyKey ?? // 幂等标识解析M-AI-07-CLEANUP
`${dto.targetType}:${dto.targetId}:${Date.now().toString(36)}`; // ═════════════════════════════════════════════════════════
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 = { const snapshotInput: QuizGenerationSnapshotInput = {
userId, userId,
targetType: dto.targetType, targetType: dto.targetType,
@ -60,10 +85,6 @@ export class QuizExecutionRouter {
const snapshot = await this.snapshotBuilder.build(snapshotInput); 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({ const job = await this.creationService.createJob({
userId, userId,
jobType: 'quiz_generation', jobType: 'quiz_generation',
@ -74,22 +95,45 @@ export class QuizExecutionRouter {
retrySnapshotContent: snapshot as unknown as Record<string, unknown>, 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( this.logger.log(
`Quiz Unified: jobId=${job.id} userId=${userId} ` + `Quiz Unified: jobId=${job.id} userId=${userId} ` +
`idSource=${idSource} idempotencyHit=${isIdempotentHit} ` +
`targetType=${dto.targetType} targetId=${dto.targetId} ` + `targetType=${dto.targetType} targetId=${dto.targetId} ` +
`questionCount=${snapshot.snapshot.questionCount}`, `questionCount=${snapshot.snapshot.questionCount}`,
); );
// 6. 返回兼容响应
return { return {
jobId: job.id, jobId: job.id,
status: 'queued', status: 'queued',
engineMode: 'unified' as const, engineMode: 'unified' as const,
lifecycleStatus: 'queued', 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> { private async shouldUseUnified(userId: string): Promise<boolean> {
try { try {
return await this.featureFlag.isEnabled(FLAG_NAME, userId); return await this.featureFlag.isEnabled(FLAG_NAME, userId);