import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { CredentialEncryptionService } from './credential-encryption.service'; import { SnapshotBuilderService } from './snapshot-builder.service'; import { PriorityRulesService } from './priority-rules.service'; import { UserAiQuotaService } from './user-ai-quota.service'; import { PlatformBudgetService } from './platform-budget.service'; import type { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CredentialResponseDto, CreateAnalysisJobDto, CreateAnalysisJobResponseDto } from './user-ai.dto'; const JOB_TYPE_CONFIG: Record = { learning_state_analysis: { promptVersion: 'learning_state_v1', outputSchemaVersion: 'analysis_output_v1' }, weak_point_analysis: { promptVersion: 'weak_point_v1', outputSchemaVersion: 'weak_point_output_v1' }, next_action_planning: { promptVersion: 'next_action_v1', outputSchemaVersion: 'next_action_output_v1' }, quiz_generation: { promptVersion: 'quiz_gen_v1', outputSchemaVersion: 'quiz_output_v1' }, flashcard_generation: { promptVersion: 'flashcard_gen_v1', outputSchemaVersion: 'flashcard_output_v1' }, }; @Injectable() export class UserAiService { constructor( private readonly prisma: PrismaService, private readonly crypto: CredentialEncryptionService, private readonly snapshotBuilder: SnapshotBuilderService, private readonly priorityRules: PriorityRulesService, private readonly quota: UserAiQuotaService, private readonly budget: PlatformBudgetService, private readonly config: ConfigService, ) {} // ══ LearningProfile ══ async getProfile(userId: string) { return this.prisma.userLearningProfile.findUnique({ where: { userId } }); } async saveProfile(userId: string, dto: SaveLearningProfileDto) { return this.prisma.userLearningProfile.upsert({ where: { userId }, create: { userId, ...dto }, update: dto, }); } // ══ AiSettings ══ async getSettings(userId: string) { let settings = await this.prisma.userAiSettings.findUnique({ where: { userId } }); if (!settings) { settings = await this.prisma.userAiSettings.create({ data: { userId } }); } return settings; } async updateSettings(userId: string, dto: UpdateAiSettingsDto) { if (dto.apiKeyMode === 'user_deepseek_key' && dto.defaultCredentialId) { const cred = await this.prisma.userModelCredential.findFirst({ where: { id: dto.defaultCredentialId, userId }, }); if (!cred) { throw new BadRequestException('Credential not found or does not belong to user'); } } return this.prisma.userAiSettings.upsert({ where: { userId }, create: { userId, ...dto }, update: dto, }); } // ══ ModelCredentials ══ async listCredentials(userId: string): Promise { const creds = await this.prisma.userModelCredential.findMany({ where: { userId, deletedAt: null }, orderBy: { createdAt: 'desc' }, }); return creds.map(c => this.toResponse(c)); } async createCredential(userId: string, dto: CreateCredentialDto): Promise { const encryptedApiKey = this.crypto.encrypt(dto.apiKey); const keyHash = this.crypto.hash(dto.apiKey); const maskedKey = this.crypto.mask(dto.apiKey); const cred = await this.prisma.userModelCredential.create({ data: { userId, provider: dto.provider, keyAlias: dto.keyAlias, encryptedApiKey, keyHash, maskedKey, }, }); return this.toResponse(cred); } async updateCredential(userId: string, credId: string, dto: UpdateCredentialDto): Promise { const cred = await this.prisma.userModelCredential.findFirst({ where: { id: credId, userId, deletedAt: null }, }); if (!cred) throw new NotFoundException('Credential not found'); const encryptedApiKey = this.crypto.encrypt(dto.apiKey); const keyHash = this.crypto.hash(dto.apiKey); const maskedKey = this.crypto.mask(dto.apiKey); const status = 'active'; const updated = await this.prisma.userModelCredential.update({ where: { id: credId }, data: { encryptedApiKey, keyHash, maskedKey, status, keyAlias: dto.keyAlias, lastErrorCode: null, lastErrorMessage: null }, }); return this.toResponse(updated); } async deleteCredential(userId: string, credId: string): Promise { const cred = await this.prisma.userModelCredential.findFirst({ where: { id: credId, userId, deletedAt: null }, }); if (!cred) throw new NotFoundException('Credential not found'); await this.prisma.userModelCredential.update({ where: { id: credId }, data: { deletedAt: new Date(), status: 'deleted' }, }); } async testCredential(userId: string, credId: string) { const cred = await this.prisma.userModelCredential.findFirst({ where: { id: credId, userId, deletedAt: null }, }); if (!cred) throw new NotFoundException('Credential not found'); let decryptedKey: string; try { decryptedKey = this.crypto.decrypt(cred.encryptedApiKey); } catch { await this.prisma.userModelCredential.update({ where: { id: credId }, data: { status: 'invalid', lastErrorCode: 'DECRYPT_FAILED' }, }); return { success: false, errorCode: 'DECRYPT_FAILED', message: '解密 Key 失败,请重新填写' }; } try { const baseUrl = this.config.get('ai.deepseek.baseUrl', 'https://api.deepseek.com'); const res = await fetch(`${baseUrl}/v1/models`, { headers: { Authorization: `Bearer ${decryptedKey}` }, signal: AbortSignal.timeout(10000), }); const success = res.ok; await this.prisma.userModelCredential.update({ where: { id: credId }, data: { status: success ? 'active' : 'invalid', lastTestedAt: new Date(), lastErrorCode: success ? null : `HTTP_${res.status}`, lastErrorMessage: success ? null : `DeepSeek returned ${res.status}`, }, }); return { success, status: success ? 'active' : 'invalid' }; } catch (err: any) { await this.prisma.userModelCredential.update({ where: { id: credId }, data: { status: 'invalid', lastTestedAt: new Date(), lastErrorCode: 'NETWORK_ERROR', lastErrorMessage: err.message?.substring(0, 500), }, }); return { success: false, errorCode: 'NETWORK_ERROR', message: err.message }; } } /** 内部使用:解密 key 并返回明文(不写日志) */ async resolveCredentialForJob(userId: string, credId: string): Promise<{ provider: string; apiKey: string }> { const cred = await this.prisma.userModelCredential.findFirst({ where: { id: credId, userId, deletedAt: null, status: 'active' }, }); if (!cred) throw new NotFoundException('Credential not found or not active'); const apiKey = this.crypto.decrypt(cred.encryptedApiKey); await this.prisma.userModelCredential.update({ where: { id: credId }, data: { lastUsedAt: new Date() }, }); return { provider: cred.provider, apiKey }; } // ══ Analysis Job Creation ══ async createAnalysisJob(userId: string, dto: CreateAnalysisJobDto): Promise { // 1. Settings check (auto-create with defaults if new user) let settings = await this.prisma.userAiSettings.findUnique({ where: { userId } }); if (!settings) { settings = await this.prisma.userAiSettings.create({ data: { userId } }); } if (!settings.allowAiAnalysis) { throw new BadRequestException({ errorCode: 'AI_ANALYSIS_DISABLED', message: 'AI analysis is disabled. Enable it in settings.' }); } // 2. Validate jobType + per-type constraints if (!Object.keys(JOB_TYPE_CONFIG).includes(dto.jobType)) { throw new BadRequestException({ errorCode: 'INVALID_JOB_TYPE', message: `Invalid jobType "${dto.jobType}". Must be one of: ${Object.keys(JOB_TYPE_CONFIG).join(', ')}`, }); } const requiresKnowledgeBase = ['quiz_generation', 'flashcard_generation']; if (requiresKnowledgeBase.includes(dto.jobType) && dto.targetType !== 'knowledge_base') { throw new BadRequestException({ errorCode: 'INVALID_TARGET_TYPE', message: `${dto.jobType} requires targetType="knowledge_base"`, }); } // 3. Idempotency check (cheapest fast-return, before quota/snapshot) if (dto.idempotencyKey) { const existing = await this.prisma.aiRuntimeJob.findUnique({ where: { idempotencyKey: dto.idempotencyKey }, select: { id: true, status: true, createdAt: true }, }); if (existing) { return { jobId: existing.id, status: existing.status, createdAt: existing.createdAt.toISOString() }; } } // 4. Resolve apiKeyMode / credentialId // NOTE: apiKeyMode may be reassigned in step 6 fallback — steps 7–11 consume the final values let apiKeyMode = dto.apiKeyMode ?? settings.apiKeyMode; let credentialId: string | undefined = dto.credentialId ?? settings.defaultCredentialId ?? undefined; if (apiKeyMode === 'user_deepseek_key' && !credentialId) { throw new BadRequestException({ errorCode: 'CREDENTIAL_REQUIRED', message: 'User key mode requires a credential.' }); } if (credentialId) { const cred = await this.prisma.userModelCredential.findFirst({ where: { id: credentialId, userId, deletedAt: null, status: 'active' }, }); if (!cred) { throw new NotFoundException({ errorCode: 'CREDENTIAL_NOT_FOUND', message: 'Credential not found or not active.' }); } } // 5. Quota check (read-only fast-fail; actual reservation at step 11) await this.quota.checkQuota(userId, apiKeyMode); // 6. Platform budget check (platform_key only) — with fallback to user key if (apiKeyMode === 'platform_key') { try { await this.budget.checkPlatformBudget('deepseek', 'deepseek-chat'); } catch (err: any) { if (err?.response?.errorCode === 'PLATFORM_CIRCUIT_OPEN' && settings.fallbackToPlatformKey && settings.defaultCredentialId) { // Fallback: apiKeyMode + credentialId reassigned — downstream steps use updated values apiKeyMode = 'user_deepseek_key'; credentialId = settings.defaultCredentialId; } else { throw err; } } } // 7. Build snapshot const snapshot = await this.snapshotBuilder.buildSnapshot(userId, dto.targetType, dto.targetId); // 8. Resolve prompt/output schema versions const config = JOB_TYPE_CONFIG[dto.jobType]; const promptVersion = dto.promptVersion ?? config.promptVersion; const outputSchemaVersion = dto.outputSchemaVersion ?? config.outputSchemaVersion; // 9. Compute priority const profile = await this.prisma.userLearningProfile.findUnique({ where: { userId } }); const priority = this.priorityRules.computeJobPriority(profile, settings, dto.targetType); // 10. Create job + per-type plan records const job = await this.prisma.aiRuntimeJob.create({ data: { userId, jobType: dto.jobType, targetType: dto.targetType, targetId: dto.targetId, snapshotId: snapshot.id, status: 'pending', priority, idempotencyKey: dto.idempotencyKey ?? null, apiKeyMode, credentialId: credentialId ?? null, modelProvider: 'deepseek', modelName: 'deepseek-chat', promptVersion, outputSchemaVersion, }, }); // 10. Create per-type plan record let planId: string | undefined; if (dto.jobType === 'quiz_generation') { const quizSourceIds = dto.sourceIds ?? []; const plan = await this.prisma.questionGenerationPlan.create({ data: { userId, jobId: job.id, snapshotId: snapshot.id, targetType: dto.targetType, targetId: dto.targetId, sourceIds: quizSourceIds as any, questionTypes: (dto.questionTypes ?? []) as any, difficultyLevel: dto.difficultyLevel ?? null, count: dto.questionCount ?? 5, status: 'pending', }, }); planId = plan.id; } if (dto.jobType === 'flashcard_generation') { const plan = await this.prisma.flashcardGenerationPlan.create({ data: { userId, jobId: job.id, snapshotId: snapshot.id, targetType: dto.targetType, targetId: dto.targetId, knowledgePointIds: (dto.knowledgePointIds ?? []) as any, difficultyLevel: dto.difficultyLevel ?? null, count: dto.cardCount ?? 5, status: 'pending', }, }); planId = plan.id; } // 11. Increment quota await this.quota.incrementJobCount(userId, apiKeyMode).catch(() => {}); return { jobId: job.id, status: job.status, createdAt: job.createdAt.toISOString(), ...(planId ? { planId } : {}) }; } async cancelJob(userId: string, jobId: string) { const job = await this.prisma.aiRuntimeJob.findFirst({ where: { id: jobId, userId }, select: { id: true, status: true }, }); if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' }); if (job.status === 'pending') { await this.prisma.aiRuntimeJob.update({ where: { id: jobId }, data: { status: 'cancelled', cancelledAt: new Date() }, }); return { jobId, status: 'cancelled' }; } if (job.status === 'locked' || job.status === 'running') { await this.prisma.aiRuntimeJob.update({ where: { id: jobId }, data: { cancelRequestedAt: new Date() }, }); return { jobId, status: 'cancel_requested' }; } throw new BadRequestException({ errorCode: 'JOB_CANNOT_CANCEL', message: `Job is in terminal state "${job.status}"`, }); } // ══ Quiz Publish ══ async publishQuiz(userId: string, quizId: string) { const quiz = await this.prisma.quiz.findFirst({ where: { id: quizId, userId }, select: { id: true, status: true, knowledgeBaseId: true }, }); if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' }); if (quiz.status !== 'ready') { throw new BadRequestException({ errorCode: 'QUIZ_NOT_READY', message: `Quiz is "${quiz.status}", only "ready" quizzes can be published`, }); } // Archive old active quizzes for same knowledge base if (quiz.knowledgeBaseId) { await this.prisma.quiz.updateMany({ where: { userId, knowledgeBaseId: quiz.knowledgeBaseId, status: 'active', id: { not: quizId } }, data: { status: 'archived' }, }); } await this.prisma.quiz.update({ where: { id: quizId }, data: { status: 'active' }, }); return { quizId, status: 'active' }; } // ══ Flashcard Publish ══ async publishFlashcard(userId: string, cardId: string) { const card = await this.prisma.flashcard.findFirst({ where: { id: cardId, userId, deletedAt: null }, select: { id: true, status: true }, }); if (!card) throw new NotFoundException({ errorCode: 'FLASHCARD_NOT_FOUND', message: 'Flashcard not found' }); if (card.status !== 'draft') { throw new BadRequestException({ errorCode: 'FLASHCARD_NOT_DRAFT', message: `Flashcard is "${card.status}", only "draft" cards can be published`, }); } await this.prisma.flashcard.update({ where: { id: cardId }, data: { status: 'active' }, }); return { cardId, status: 'active' }; } // ══ Job Status Query ══ async getJob(userId: string, jobId: string) { const job = await this.prisma.aiRuntimeJob.findFirst({ where: { id: jobId, userId }, select: { id: true, jobType: true, targetType: true, targetId: true, status: true, priority: true, snapshotId: true, attemptNo: true, retryCount: true, maxRetryCount: true, errorCode: true, errorMessage: true, cancelRequestedAt: true, cancelledAt: true, startedAt: true, finishedAt: true, createdAt: true, updatedAt: true, }, }); if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' }); return job; } async listJobs(userId: string, status?: string, take: number = 20) { const where: any = { userId }; if (status) where.status = status; return this.prisma.aiRuntimeJob.findMany({ where, select: { id: true, jobType: true, targetType: true, targetId: true, status: true, priority: true, errorCode: true, cancelRequestedAt: true, startedAt: true, finishedAt: true, createdAt: true, }, orderBy: { createdAt: 'desc' }, take: Math.min(take, 50), }); } // ══ Analysis Results Query ══ async getAnalysis(userId: string, analysisId: string) { const a = await this.prisma.aiLearningAnalysis.findFirst({ where: { id: analysisId, userId }, select: { id: true, userId: true, jobId: true, snapshotId: true, targetType: true, targetId: true, learningState: true, summary: true, riskLevel: true, confidence: true, evidence: true, nextActionIds: true, promptVersion: true, schemaVersion: true, createdAt: true, updatedAt: true, }, }); if (!a) throw new NotFoundException({ errorCode: 'ANALYSIS_NOT_FOUND', message: 'Analysis not found' }); return a; } async listAnalyses(userId: string, targetType?: string, targetId?: string, take: number = 20) { const where: any = { userId }; if (targetType) where.targetType = targetType; if (targetId) where.targetId = targetId; return this.prisma.aiLearningAnalysis.findMany({ where, select: { id: true, targetType: true, targetId: true, learningState: true, riskLevel: true, confidence: true, summary: true, createdAt: true, }, orderBy: { createdAt: 'desc' }, take: Math.min(take, 50), }); } // ══ Suggestions / Weak Points Query ══ async listRecommendations(userId: string, targetType?: string, targetId?: string, status?: string, take: number = 20) { const where: any = { userId }; if (targetType) where.targetType = targetType; if (targetId) where.targetId = targetId; if (status) where.status = status; return this.prisma.nextActionRecommendation.findMany({ where, select: { id: true, actionType: true, targetType: true, targetId: true, title: true, reason: true, priority: true, estimatedMinutes: true, deviceSuitability: true, status: true, createdAt: true, }, orderBy: { priority: 'asc' }, take: Math.min(take, 50), }); } async listWeakPoints(userId: string, targetType?: string, targetId?: string, status?: string, take: number = 20) { const where: any = { userId }; if (targetType) where.targetType = targetType; if (targetId) where.targetId = targetId; if (status) where.status = status; return this.prisma.weakPointCandidate.findMany({ where, select: { id: true, knowledgePointId: true, title: true, reason: true, confidence: true, evidence: true, status: true, targetType: true, targetId: true, createdAt: true, }, orderBy: { confidence: 'desc' }, take: Math.min(take, 50), }); } // ══ Quiz Query ══ async getQuiz(userId: string, quizId: string) { const quiz = await this.prisma.quiz.findFirst({ where: { id: quizId, userId }, select: { id: true, knowledgeBaseId: true, title: true, description: true, questionCount: true, sourceType: true, sourceId: true, status: true, createdAt: true, updatedAt: true, }, }); if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' }); return quiz; } async getQuizQuestions(userId: string, quizId: string, attemptId?: string) { const quiz = await this.prisma.quiz.findFirst({ where: { id: quizId, userId }, select: { id: true } }); if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' }); // M-QUIZ-01-01: 若提供了 attemptId,按当前 Attempt 状态控制答案可见性 // 未提供 attemptId 时,不返回 answer/explanation(安全默认) let isFinished = false; if (attemptId) { const attempt = await this.prisma.quizAttempt.findFirst({ where: { id: attemptId, userId, quizId }, select: { finishedAt: true }, }); if (attempt) { isFinished = attempt.finishedAt !== null; } } return this.prisma.quizQuestion.findMany({ where: { quizId }, select: { id: true, type: true, stem: true, options: true, // M-QUIZ-01-01: 仅当前 Attempt 已提交后才返回 answer 和 explanation // 不存在基于"任意历史 Attempt"的答案泄漏 ...(isFinished ? { answer: true, explanation: true } : {}), sourceBlockIds: true, orderIndex: true, }, orderBy: { orderIndex: 'asc' }, }); } async listQuizzes(userId: string, knowledgeBaseId?: string, status?: string, take: number = 20) { const where: any = { userId }; if (knowledgeBaseId) where.knowledgeBaseId = knowledgeBaseId; if (status) where.status = status; return this.prisma.quiz.findMany({ where, select: { id: true, knowledgeBaseId: true, title: true, questionCount: true, sourceType: true, status: true, createdAt: true, }, orderBy: { createdAt: 'desc' }, take: Math.min(take, 50), }); } // ══ Flashcard Query ══ async getFlashcard(userId: string, cardId: string) { const card = await this.prisma.flashcard.findFirst({ where: { id: cardId, userId, deletedAt: null }, select: { id: true, front: true, back: true, hint: true, difficultyLevel: true, knowledgePointId: true, sourceBlockIds: true, sourceType: true, sourceId: true, generatedByJobId: true, status: true, createdAt: true, updatedAt: true, }, }); if (!card) throw new NotFoundException({ errorCode: 'FLASHCARD_NOT_FOUND', message: 'Flashcard not found' }); return card; } async listFlashcards(userId: string, knowledgePointId?: string, status?: string, take: number = 20) { const where: any = { userId, deletedAt: null }; if (knowledgePointId) where.knowledgePointId = knowledgePointId; if (status) where.status = status; return this.prisma.flashcard.findMany({ where, select: { id: true, front: true, difficultyLevel: true, knowledgePointId: true, sourceType: true, status: true, createdAt: true, }, orderBy: { createdAt: 'desc' }, take: Math.min(take, 50), }); } // ══ Re-analysis Trigger ══ async triggerReanalysis(userId: string, targetType: string, targetId: string) { return this.createAnalysisJob(userId, { jobType: 'learning_state_analysis', targetType, targetId, }); } // ══ Artifact Feedback ══ async submitArtifactFeedback( userId: string, artifactType: string, artifactId: string, dto: { feedbackType: string; reason?: string }, ) { const exists = await this.checkArtifactExists(userId, artifactType, artifactId); if (!exists) throw new NotFoundException({ errorCode: 'ARTIFACT_NOT_FOUND', message: `${artifactType} not found` }); return this.prisma.aiArtifactFeedback.create({ data: { userId, artifactType, artifactId, feedbackType: dto.feedbackType, reason: dto.reason ?? null }, select: { id: true, feedbackType: true, createdAt: true }, }); } private async checkArtifactExists(userId: string, artifactType: string, artifactId: string): Promise { if (artifactType === 'analysis') { const a = await this.prisma.aiLearningAnalysis.findFirst({ where: { id: artifactId, userId }, select: { id: true } }); return !!a; } if (artifactType === 'quiz') { const q = await this.prisma.quiz.findFirst({ where: { id: artifactId, userId }, select: { id: true } }); return !!q; } if (artifactType === 'flashcard') { const f = await this.prisma.flashcard.findFirst({ where: { id: artifactId, userId, deletedAt: null }, select: { id: true } }); return !!f; } return false; } // ══ General Feedback ══ async submitFeedback(userId: string, dto: { category: string; content: string; email?: string; deviceInfo?: any }) { return this.prisma.feedback.create({ data: { userId, category: dto.category, content: dto.content, email: dto.email ?? null, deviceInfo: dto.deviceInfo ?? undefined, status: 'open', }, select: { id: true, status: true, createdAt: true }, }); } // ══ Notifications ══ async listNotifications(userId: string, take: number = 20) { return this.prisma.notification.findMany({ where: { userId }, select: { id: true, type: true, title: true, content: true, data: true, readAt: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: Math.min(take, 50), }); } private toResponse(c: any): CredentialResponseDto { return { id: c.id, provider: c.provider, keyAlias: c.keyAlias, maskedKey: c.maskedKey, status: c.status, lastTestedAt: c.lastTestedAt?.toISOString(), lastUsedAt: c.lastUsedAt?.toISOString(), createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), }; } }