feat: 系统密钥管理模块(数据库加密存储)

后端:
- 新增 system_secrets 表(AES-256-GCM 加密存储)
- 新增 crypto.util.ts 加解密工具
- 新增 SecretService 共享服务(CRUD + 加解密)
- 新增 AdminSecretController 管理端 API(仅超管)
- API 返回值脱敏(*** + 最后4位)

前端(平台后台):
- 新增系统密钥管理页面(按分组展示、CRUD 操作)
- 侧边栏新增「系统密钥」菜单

管理员可在后台网页管理所有密钥,不再需要 SSH 到服务器改配置

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:03:37 +08:00
parent b358dbdab1
commit 3b75e26599
14 changed files with 723 additions and 0 deletions
@@ -0,0 +1,64 @@
import * as crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
/**
* 获取加密密钥
*/
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
throw new Error('ENCRYPTION_KEY 环境变量未配置');
}
return Buffer.from(key, 'hex');
}
/**
* AES-256-GCM 加密
* 返回格式:iv:authTag:ciphertext(均为 base64
*/
export function encrypt(plaintext: string): string {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
/**
* AES-256-GCM 解密
*/
export function decrypt(encryptedValue: string): string {
const key = getEncryptionKey();
const parts = encryptedValue.split(':');
if (parts.length !== 3) {
throw new Error('密文格式错误');
}
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const ciphertext = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* 脱敏显示:保留最后4位,前面用 *** 替代
*/
export function maskValue(value: string): string {
if (!value || value.length <= 4) {
return '****';
}
return '***' + value.slice(-4);
}
@@ -0,0 +1,34 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('system_secrets')
export class SystemSecret {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Column({ name: 'secret_key', length: 100, comment: '密钥名' })
secretKey: string;
@Column({ name: 'secret_value', type: 'text', comment: '加密后的值' })
secretValue: string;
@Column({ name: 'description', length: 200, nullable: true, comment: '说明' })
description: string;
@Column({ name: 'group_name', length: 50, default: 'default', comment: '分组' })
groupName: string;
@Column({ type: 'enum', enum: ['all', 'prod', 'test'], default: 'all', comment: '适用环境' })
env: 'all' | 'prod' | 'test';
@CreateDateColumn({ name: 'created_at', comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', comment: '更新时间' })
updatedAt: Date;
}
@@ -11,6 +11,7 @@ import { AdminConfigModule } from './config/config.module';
import { AdminFinanceModule } from './finance/finance.module';
import { AdminWebsiteModule } from './website/website.module';
import { AdminManageModule } from './admin-manage/admin-manage.module';
import { AdminSecretModule } from './secret/secret.module';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { AdminManageModule } from './admin-manage/admin-manage.module';
AdminFinanceModule,
AdminWebsiteModule,
AdminManageModule,
AdminSecretModule,
],
})
export class AdminModule {}
@@ -0,0 +1,69 @@
import { IsString, IsOptional, IsEnum, MaxLength, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateSecretDto {
@ApiProperty({ description: '密钥名', example: 'WECHAT_SECRET' })
@IsString()
@MinLength(1)
@MaxLength(100)
secretKey: string;
@ApiProperty({ description: '密钥值(明文,服务端加密存储)' })
@IsString()
@MinLength(1)
secretValue: string;
@ApiPropertyOptional({ description: '说明', example: '微信小程序 Secret' })
@IsString()
@IsOptional()
@MaxLength(200)
description?: string;
@ApiPropertyOptional({ description: '分组', example: 'wechat', default: 'default' })
@IsString()
@IsOptional()
@MaxLength(50)
groupName?: string;
@ApiPropertyOptional({ description: '适用环境', enum: ['all', 'prod', 'test'], default: 'all' })
@IsEnum(['all', 'prod', 'test'])
@IsOptional()
env?: string;
}
export class UpdateSecretDto {
@ApiPropertyOptional({ description: '密钥值(明文,服务端加密存储)' })
@IsString()
@IsOptional()
@MinLength(1)
secretValue?: string;
@ApiPropertyOptional({ description: '说明' })
@IsString()
@IsOptional()
@MaxLength(200)
description?: string;
@ApiPropertyOptional({ description: '分组' })
@IsString()
@IsOptional()
@MaxLength(50)
groupName?: string;
@ApiPropertyOptional({ description: '适用环境', enum: ['all', 'prod', 'test'] })
@IsEnum(['all', 'prod', 'test'])
@IsOptional()
env?: string;
}
export class QuerySecretDto {
@ApiPropertyOptional({ description: '按分组筛选' })
@IsString()
@IsOptional()
groupName?: string;
@ApiPropertyOptional({ description: '按环境筛选', enum: ['all', 'prod', 'test'] })
@IsEnum(['all', 'prod', 'test'])
@IsOptional()
env?: string;
}
@@ -0,0 +1,64 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { RolesGuard } from '@/common/guards/roles.guard';
import { Roles } from '@/common/decorators/roles.decorator';
import { AdminRole } from '@/entities/admin.entity';
import { SecretService } from '@/modules/shared/secret/secret.service';
import { CreateSecretDto, UpdateSecretDto, QuerySecretDto } from './dto/secret.dto';
@ApiTags('管理端-系统密钥')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(AdminRole.SUPER_ADMIN)
@Controller('admin/secrets')
export class AdminSecretController {
constructor(private readonly secretService: SecretService) {}
@Get('group/list')
@ApiOperation({ summary: '获取密钥分组列表' })
getGroups() {
return this.secretService.getGroups();
}
@Get()
@ApiOperation({ summary: '获取密钥列表(值脱敏)' })
findAll(@Query() query: QuerySecretDto) {
return this.secretService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: '获取密钥详情(值脱敏)' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.secretService.findOne(id);
}
@Post()
@ApiOperation({ summary: '新增密钥' })
create(@Body() dto: CreateSecretDto) {
return this.secretService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: '更新密钥' })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSecretDto) {
return this.secretService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: '删除密钥' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.secretService.remove(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SecretModule } from '@/modules/shared/secret/secret.module';
import { AdminSecretController } from './secret.controller';
@Module({
imports: [SecretModule],
controllers: [AdminSecretController],
})
export class AdminSecretModule {}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SystemSecret } from '@/entities/system-secret.entity';
import { SecretService } from './secret.service';
@Module({
imports: [TypeOrmModule.forFeature([SystemSecret])],
providers: [SecretService],
exports: [SecretService],
})
export class SecretModule {}
@@ -0,0 +1,125 @@
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SystemSecret } from '@/entities/system-secret.entity';
import { encrypt, decrypt, maskValue } from '@/common/utils/crypto.util';
@Injectable()
export class SecretService {
private readonly logger = new Logger(SecretService.name);
constructor(
@InjectRepository(SystemSecret)
private readonly secretRepo: Repository<SystemSecret>,
) {}
/**
* 获取所有密钥列表(脱敏)
*/
async findAll(query?: { groupName?: string; env?: string }) {
const where: any = {};
if (query?.groupName) where.groupName = query.groupName;
if (query?.env) where.env = query.env;
const secrets = await this.secretRepo.find({ where, order: { groupName: 'ASC', secretKey: 'ASC' } });
return secrets.map(s => ({
id: s.id,
secretKey: s.secretKey,
maskedValue: maskValue(decrypt(s.secretValue)),
description: s.description,
groupName: s.groupName,
env: s.env,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
}));
}
/**
* 获取单个密钥详情(脱敏)
*/
async findOne(id: number) {
const secret = await this.secretRepo.findOne({ where: { id } });
if (!secret) throw new NotFoundException('密钥不存在');
return {
id: secret.id,
secretKey: secret.secretKey,
maskedValue: maskValue(decrypt(secret.secretValue)),
description: secret.description,
groupName: secret.groupName,
env: secret.env,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt,
};
}
/**
* 获取解密后的密钥值(供其他服务调用)
*/
async getDecryptedValue(secretKey: string, env: string = 'all'): Promise<string | null> {
const secret = await this.secretRepo.findOne({
where: [
{ secretKey, env: 'all' },
{ secretKey, env },
],
});
if (!secret) return null;
return decrypt(secret.secretValue);
}
/**
* 创建密钥
*/
async create(data: { secretKey: string; secretValue: string; description?: string; groupName?: string; env?: string }) {
// 检查是否已存在
const existing = await this.secretRepo.findOne({
where: { secretKey: data.secretKey, env: (data.env as any) || 'all' },
});
if (existing) throw new ConflictException(`密钥 ${data.secretKey} 已存在`);
const secret = this.secretRepo.create({
secretKey: data.secretKey,
secretValue: encrypt(data.secretValue),
description: data.description,
groupName: data.groupName || 'default',
env: data.env || 'all',
});
return this.secretRepo.save(secret);
}
/**
* 更新密钥
*/
async update(id: number, data: { secretValue?: string; description?: string; groupName?: string; env?: string }) {
const secret = await this.secretRepo.findOne({ where: { id } });
if (!secret) throw new NotFoundException('密钥不存在');
if (data.secretValue !== undefined) {
secret.secretValue = encrypt(data.secretValue);
}
if (data.description !== undefined) secret.description = data.description;
if (data.groupName !== undefined) secret.groupName = data.groupName;
if (data.env !== undefined) secret.env = data.env as any;
return this.secretRepo.save(secret);
}
/**
* 删除密钥
*/
async remove(id: number) {
const secret = await this.secretRepo.findOne({ where: { id } });
if (!secret) throw new NotFoundException('密钥不存在');
await this.secretRepo.remove(secret);
}
/**
* 获取所有分组
*/
async getGroups() {
const result = await this.secretRepo
.createQueryBuilder('s')
.select('DISTINCT s.groupName', 'groupName')
.getRawMany();
return result.map(r => r.groupName);
}
}
@@ -3,6 +3,7 @@ import { UploadModule } from './upload/upload.module';
import { FinanceModule } from './finance/finance.module';
import { CouponModule } from './coupon/coupon.module';
import { ConfigModule } from './config/config.module';
import { SecretModule } from './secret/secret.module';
@Module({
imports: [
@@ -10,12 +11,14 @@ import { ConfigModule } from './config/config.module';
FinanceModule,
CouponModule,
ConfigModule,
SecretModule,
],
exports: [
UploadModule,
FinanceModule,
CouponModule,
ConfigModule,
SecretModule,
],
})
export class SharedModule {}