All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 43s
- Replace hardcoded 'https://api.deepseek.com/v1/models' with config lookup - Single source of truth via ai.deepseek.baseUrl (DEEPSEEK_BASE_URL env) - Add ConfigService dep to UserAiService constructor + test mock Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
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<string, { promptVersion: string; outputSchemaVersion: string }> = {
|
||
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<CredentialResponseDto[]> {
|
||
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<CredentialResponseDto> {
|
||
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<CredentialResponseDto> {
|
||
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<void> {
|
||
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<string>('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<CreateAnalysisJobResponseDto> {
|
||
// 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 plan = await this.prisma.questionGenerationPlan.create({
|
||
data: {
|
||
userId,
|
||
jobId: job.id,
|
||
snapshotId: snapshot.id,
|
||
targetType: dto.targetType,
|
||
targetId: dto.targetId,
|
||
knowledgePointIds: (dto.knowledgePointIds ?? []) 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) {
|
||
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' });
|
||
return this.prisma.quizQuestion.findMany({
|
||
where: { quizId },
|
||
select: {
|
||
id: true, type: true, stem: true, options: true,
|
||
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<boolean> {
|
||
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(),
|
||
};
|
||
}
|
||
}
|