diff --git a/src/app.module.ts b/src/app.module.ts index 049bb26..b0647d8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -65,6 +65,7 @@ import { ReadingModule } from './modules/reading/reading.module'; import { MaterialReadingProgressModule } from './modules/material-reading-progress/material-reading-progress.module'; import { LearningRecordModule } from './modules/learning-record/learning-record.module'; import { TemporaryReadingMaterialModule } from './modules/temporary-reading-material/temporary-reading-material.module'; +import { AiRuntimeModule } from './modules/ai-runtime/ai-runtime.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; @@ -176,6 +177,7 @@ import appleConfig from './config/apple.config'; MaterialReadingProgressModule, LearningRecordModule, TemporaryReadingMaterialModule, + AiRuntimeModule, ], providers: [ { provide: APP_GUARD, useClass: RateLimitGuard }, diff --git a/src/modules/ai-runtime/ai-runtime.module.ts b/src/modules/ai-runtime/ai-runtime.module.ts new file mode 100644 index 0000000..6b92009 --- /dev/null +++ b/src/modules/ai-runtime/ai-runtime.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from '../../infrastructure/database/prisma.module'; +import { UserAiController } from './user-ai.controller'; +import { UserAiService } from './user-ai.service'; +import { CredentialEncryptionService } from './credential-encryption.service'; + +@Module({ + imports: [ConfigModule, PrismaModule], + controllers: [UserAiController], + providers: [UserAiService, CredentialEncryptionService], + exports: [UserAiService, CredentialEncryptionService], +}) +export class AiRuntimeModule {} diff --git a/src/modules/ai-runtime/credential-encryption.service.ts b/src/modules/ai-runtime/credential-encryption.service.ts new file mode 100644 index 0000000..079a4fc --- /dev/null +++ b/src/modules/ai-runtime/credential-encryption.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; + +@Injectable() +export class CredentialEncryptionService { + private readonly logger = new Logger(CredentialEncryptionService.name); + private readonly algorithm = 'aes-256-gcm'; + private readonly keyLength = 32; + private readonly ivLength = 16; + private readonly tagLength = 16; + + constructor(private readonly config: ConfigService) {} + + private getEncryptionKey(): Buffer { + const key = this.config.get('CREDENTIAL_ENCRYPTION_KEY'); + if (!key) { + throw new Error('CREDENTIAL_ENCRYPTION_KEY not configured'); + } + return crypto.scryptSync(key, 'ai-runtime-salt', this.keyLength); + } + + /** AES-256-GCM 加密 → base64 */ + encrypt(plaintext: string): string { + const key = this.getEncryptionKey(); + const iv = crypto.randomBytes(this.ivLength); + const cipher = crypto.createCipheriv(this.algorithm, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // iv + tag + ciphertext + const combined = Buffer.concat([iv, tag, encrypted]); + return combined.toString('base64'); + } + + /** AES-256-GCM 解密 ← base64 */ + decrypt(encoded: string): string { + const key = this.getEncryptionKey(); + const combined = Buffer.from(encoded, 'base64'); + const iv = combined.subarray(0, this.ivLength); + const tag = combined.subarray(this.ivLength, this.ivLength + this.tagLength); + const encrypted = combined.subarray(this.ivLength + this.tagLength); + const decipher = crypto.createDecipheriv(this.algorithm, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } + + /** SHA-256 hash → hex,用于重复检测 */ + hash(plaintext: string): string { + return crypto.createHash('sha256').update(plaintext).digest('hex'); + } + + /** 脱敏展示:sk-****xxxx */ + mask(plaintext: string): string { + if (plaintext.length <= 8) { + return plaintext.substring(0, 2) + '****'; + } + return plaintext.substring(0, 4) + '****' + plaintext.substring(plaintext.length - 4); + } + + /** 日志脱敏:移除所有疑似 API Key 的字符串 */ + redact(msg: string): string { + return msg.replace(/\b(sk-[a-zA-Z0-9]{10,})\b/g, '***REDACTED***'); + } + + logSafe(msg: string): void { + this.logger.log(this.redact(msg)); + } +} diff --git a/src/modules/ai-runtime/user-ai.controller.ts b/src/modules/ai-runtime/user-ai.controller.ts new file mode 100644 index 0000000..e1ee313 --- /dev/null +++ b/src/modules/ai-runtime/user-ai.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Put, Post, Delete, Param, Body, Req } from '@nestjs/common'; +import { UserAiService } from './user-ai.service'; +import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto } from './user-ai.dto'; + +@Controller('ai') +export class UserAiController { + constructor(private readonly service: UserAiService) {} + + // ── Profile ── + + @Get('profile') + async getProfile(@Req() req: any) { + return this.service.getProfile(req.user.id) ?? {}; + } + + @Put('profile') + async saveProfile(@Req() req: any, @Body() dto: SaveLearningProfileDto) { + return this.service.saveProfile(req.user.id, dto); + } + + // ── Settings ── + + @Get('settings') + async getSettings(@Req() req: any) { + return this.service.getSettings(req.user.id); + } + + @Put('settings') + async updateSettings(@Req() req: any, @Body() dto: UpdateAiSettingsDto) { + return this.service.updateSettings(req.user.id, dto); + } + + // ── Model Credentials ── + + @Get('model-credentials') + async listCredentials(@Req() req: any) { + return this.service.listCredentials(req.user.id); + } + + @Post('model-credentials') + async createCredential(@Req() req: any, @Body() dto: CreateCredentialDto) { + return this.service.createCredential(req.user.id, dto); + } + + @Put('model-credentials/:id') + async updateCredential(@Req() req: any, @Param('id') id: string, @Body() dto: UpdateCredentialDto) { + return this.service.updateCredential(req.user.id, id, dto); + } + + @Delete('model-credentials/:id') + async deleteCredential(@Req() req: any, @Param('id') id: string) { + await this.service.deleteCredential(req.user.id, id); + return { ok: true }; + } + + @Post('model-credentials/:id/test') + async testCredential(@Req() req: any, @Param('id') id: string) { + return this.service.testCredential(req.user.id, id); + } +} diff --git a/src/modules/ai-runtime/user-ai.dto.ts b/src/modules/ai-runtime/user-ai.dto.ts new file mode 100644 index 0000000..90ab7be --- /dev/null +++ b/src/modules/ai-runtime/user-ai.dto.ts @@ -0,0 +1,59 @@ +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +// ── LearningProfile ── + +export class SaveLearningProfileDto { + @IsOptional() @IsString() learningGoal?: string; + @IsOptional() @IsString() currentLevel?: string; + @IsOptional() @IsInt() @Min(1) @Max(480) dailyAvailableMinutes?: number; + @IsOptional() @IsString() qualityPreference?: string; + @IsOptional() @IsString() ageRange?: string; + @IsOptional() @IsString() occupation?: string; + @IsOptional() @IsString() examTarget?: string; + @IsOptional() @IsString() learningDeadline?: string; + @IsOptional() @IsString() learningStyle?: string; + @IsOptional() @IsString() aiAcceptanceLevel?: string; + @IsOptional() @IsString() digitalSkillLevel?: string; + @IsOptional() @IsArray() @IsString({ each: true }) preferredQuestionTypes?: string[]; + @IsOptional() @IsString() preferredLanguage?: string; +} + +// ── AiSettings ── + +export class UpdateAiSettingsDto { + @IsOptional() @IsBoolean() allowAiAnalysis?: boolean; + @IsOptional() @IsBoolean() allowUseLearningBehavior?: boolean; + @IsOptional() @IsBoolean() allowUseUserProfile?: boolean; + @IsOptional() @IsBoolean() allowUseDocumentContent?: boolean; + @IsOptional() @IsBoolean() allowStoreAiAnalysisHistory?: boolean; + @IsOptional() @IsString() apiKeyMode?: string; + @IsOptional() @IsString() defaultCredentialId?: string; + @IsOptional() @IsBoolean() fallbackToPlatformKey?: boolean; + @IsOptional() @IsInt() @Min(0) maxDailyAiJobs?: number; + @IsOptional() @IsInt() @Min(0) maxDailyTokenBudget?: number; +} + +// ── ModelCredential ── + +export class CreateCredentialDto { + @IsString() provider!: string; + @IsString() apiKey!: string; + @IsOptional() @IsString() keyAlias?: string; +} + +export class UpdateCredentialDto { + @IsString() apiKey!: string; + @IsOptional() @IsString() keyAlias?: string; +} + +export class CredentialResponseDto { + id!: string; + provider!: string; + keyAlias?: string; + maskedKey!: string; + status!: string; + lastTestedAt?: string; + lastUsedAt?: string; + createdAt!: string; + updatedAt!: string; +} diff --git a/src/modules/ai-runtime/user-ai.service.ts b/src/modules/ai-runtime/user-ai.service.ts new file mode 100644 index 0000000..92a0388 --- /dev/null +++ b/src/modules/ai-runtime/user-ai.service.ts @@ -0,0 +1,185 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { CredentialEncryptionService } from './credential-encryption.service'; +import type { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CredentialResponseDto } from './user-ai.dto'; + +@Injectable() +export class UserAiService { + constructor( + private readonly prisma: PrismaService, + private readonly crypto: CredentialEncryptionService, + ) {} + + // ══ 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 res = await fetch('https://api.deepseek.com/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); + return { provider: cred.provider, apiKey }; + } + + 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(), + }; + } +}