feat: 用户 AI 设置与 Key 管理 API (API-AI-011~015)
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 10s
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 10s
- UserAiController: GET/PUT /ai/profile, GET/PUT /ai/settings - UserAiController: CRUD /ai/model-credentials, POST test - UserAiService: Profile upsert, Settings默认创建, Key 生命周期 - CredentialEncryptionService: AES-256-GCM 加解密, SHA-256 hash, mask脱敏, 日志redact - AiRuntimeModule 注册到 AppModule Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6888fe1d12
commit
4cf2aa99fd
@ -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 },
|
||||
|
||||
14
src/modules/ai-runtime/ai-runtime.module.ts
Normal file
14
src/modules/ai-runtime/ai-runtime.module.ts
Normal file
@ -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 {}
|
||||
68
src/modules/ai-runtime/credential-encryption.service.ts
Normal file
68
src/modules/ai-runtime/credential-encryption.service.ts
Normal file
@ -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<string>('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));
|
||||
}
|
||||
}
|
||||
60
src/modules/ai-runtime/user-ai.controller.ts
Normal file
60
src/modules/ai-runtime/user-ai.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
59
src/modules/ai-runtime/user-ai.dto.ts
Normal file
59
src/modules/ai-runtime/user-ai.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
185
src/modules/ai-runtime/user-ai.service.ts
Normal file
185
src/modules/ai-runtime/user-ai.service.ts
Normal file
@ -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<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 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user