Quota reserve is now centralized in UserAiController.createAnalysisJob. Router no longer needs QuotaGuardService injection. Co-Authored-By: Claude <noreply@anthropic.com>
148 lines
5.7 KiB
TypeScript
148 lines
5.7 KiB
TypeScript
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';
|
||
import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
|
||
import { UserAiService } from './user-ai.service';
|
||
import type { CreateAnalysisJobDto } from './user-ai.dto';
|
||
|
||
/**
|
||
* 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);
|
||
|
||
constructor(
|
||
private readonly featureFlag: FeatureFlagService,
|
||
private readonly snapshotBuilder: QuizGenerationSnapshotBuilder,
|
||
private readonly creationService: AiJobCreationService,
|
||
private readonly legacyService: UserAiService,
|
||
) {}
|
||
|
||
async generateQuiz(userId: string, dto: CreateAnalysisJobDto) {
|
||
// M-MEMBER-01-CLOSE-01A: 额度预占已由 UserAiController 集中处理,此处不再重复 reserve
|
||
|
||
const useUnified = await this.shouldUseUnified(userId);
|
||
|
||
if (!useUnified) {
|
||
this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=legacy for userId=${userId}`);
|
||
return this.legacyService.createAnalysisJob(userId, dto);
|
||
}
|
||
|
||
this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=unified for userId=${userId}`);
|
||
|
||
// ═════════════════════════════════════════════════════════
|
||
// 幂等标识解析(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 创建
|
||
// ═════════════════════════════════════════════════════════
|
||
|
||
const snapshotInput: QuizGenerationSnapshotInput = {
|
||
userId,
|
||
targetType: dto.targetType,
|
||
targetId: dto.targetId,
|
||
submissionId,
|
||
questionCount: dto.questionCount,
|
||
questionTypes: dto.questionTypes,
|
||
difficultyLevel: dto.difficultyLevel,
|
||
knowledgePointIds: dto.knowledgePointIds,
|
||
};
|
||
|
||
const snapshot = await this.snapshotBuilder.build(snapshotInput);
|
||
|
||
const job = await this.creationService.createJob({
|
||
userId,
|
||
jobType: 'quiz_generation',
|
||
triggerType: 'user_api',
|
||
targetType: dto.targetType,
|
||
targetId: dto.targetId,
|
||
idempotencyKey,
|
||
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}`,
|
||
);
|
||
|
||
return {
|
||
jobId: job.id,
|
||
status: 'queued',
|
||
engineMode: 'unified' as const,
|
||
lifecycleStatus: 'queued',
|
||
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);
|
||
} catch (err: any) {
|
||
this.logger.warn(`FeatureFlag query failed, falling back to legacy: ${err.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
}
|