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, }); 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 { 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; } } }