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 { MaterialReadingProgressModule } from './modules/material-reading-progress/material-reading-progress.module';
|
||||||
import { LearningRecordModule } from './modules/learning-record/learning-record.module';
|
import { LearningRecordModule } from './modules/learning-record/learning-record.module';
|
||||||
import { TemporaryReadingMaterialModule } from './modules/temporary-reading-material/temporary-reading-material.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './common/guards/roles.guard';
|
import { RolesGuard } from './common/guards/roles.guard';
|
||||||
@ -176,6 +177,7 @@ import appleConfig from './config/apple.config';
|
|||||||
MaterialReadingProgressModule,
|
MaterialReadingProgressModule,
|
||||||
LearningRecordModule,
|
LearningRecordModule,
|
||||||
TemporaryReadingMaterialModule,
|
TemporaryReadingMaterialModule,
|
||||||
|
AiRuntimeModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
{ 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