feat: 用户 AI 设置与 Key 管理 API (API-AI-011~015)
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:
wangdl 2026-06-11 20:39:29 +08:00
parent 6888fe1d12
commit 4cf2aa99fd
6 changed files with 388 additions and 0 deletions

View File

@ -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 },

View 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 {}

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

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

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

View 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(),
};
}
}