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 { 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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user