api-server/src/modules/ai-runtime/quiz-execution-router.ts
wangdl 6a5ab31ddd
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 39s
Deploy API Server / current-integration (push) Successful in 33s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Has been cancelled
fix(quota): remove duplicate quiz_generation reserve in router
Quota reserve is now centralized in UserAiController.createAnalysisJob.
Router no longer needs QuotaGuardService injection.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 20:19:39 +08:00

148 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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