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:
@@ -17,6 +17,7 @@ import CouponList from '@/pages/coupon/CouponList';
|
||||
import CouponForm from '@/pages/coupon/CouponForm';
|
||||
import Promotion from '@/pages/Promotion';
|
||||
import SystemSettings from '@/pages/SystemSettings';
|
||||
import SystemSecrets from '@/pages/SystemSecrets';
|
||||
import FinanceSettlements from '@/pages/finance/Settlements';
|
||||
import FinanceWithdrawals from '@/pages/finance/Withdrawals';
|
||||
import FinanceEarnings from '@/pages/finance/Earnings';
|
||||
@@ -74,6 +75,7 @@ const App: React.FC = () => (
|
||||
<Route path="promotions" element={<Promotion />} />
|
||||
<Route path="admins" element={<AdminManage />} />
|
||||
<Route path="settings" element={<SystemSettings />} />
|
||||
<Route path="system-secrets" element={<SystemSecrets />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 获取密钥列表
|
||||
export function getSecrets(params?: any) {
|
||||
return request.get('/api/admin/secrets', { params });
|
||||
}
|
||||
|
||||
// 获取密钥详情
|
||||
export function getSecret(id: number) {
|
||||
return request.get(`/api/admin/secrets/${id}`);
|
||||
}
|
||||
|
||||
// 新增密钥
|
||||
export function createSecret(data: any) {
|
||||
return request.post('/api/admin/secrets', data);
|
||||
}
|
||||
|
||||
// 更新密钥
|
||||
export function updateSecret(id: number, data: any) {
|
||||
return request.put(`/api/admin/secrets/${id}`, data);
|
||||
}
|
||||
|
||||
// 删除密钥
|
||||
export function deleteSecret(id: number) {
|
||||
return request.delete(`/api/admin/secrets/${id}`);
|
||||
}
|
||||
|
||||
// 获取分组列表
|
||||
export function getSecretGroups() {
|
||||
return request.get('/api/admin/secrets/group/list');
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TagOutlined,
|
||||
CreditCardOutlined,
|
||||
SafetyOutlined,
|
||||
KeyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
|
||||
@@ -56,6 +57,7 @@ const menuItems = [
|
||||
{ key: '/promotions', icon: <GiftOutlined />, label: '推广管理' },
|
||||
{ key: '/admins', icon: <SafetyOutlined />, label: '管理员管理' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/system-secrets', icon: <KeyOutlined />, label: '系统密钥' },
|
||||
];
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, Popconfirm, Tag, Card } from 'antd';
|
||||
import { PlusOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { getSecrets, createSecret, updateSecret, deleteSecret } from '@/api/secrets';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
// 分组中文映射
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
database: '数据库',
|
||||
jwt: 'JWT',
|
||||
sms: '短信',
|
||||
wechat: '微信',
|
||||
wechat_pay: '微信支付',
|
||||
alipay: '支付宝',
|
||||
default: '其他',
|
||||
};
|
||||
|
||||
// 分组颜色
|
||||
const GROUP_COLORS: Record<string, string> = {
|
||||
database: 'red',
|
||||
jwt: 'orange',
|
||||
sms: 'green',
|
||||
wechat: 'blue',
|
||||
wechat_pay: 'cyan',
|
||||
alipay: 'purple',
|
||||
default: 'default',
|
||||
};
|
||||
|
||||
// 环境映射
|
||||
const ENV_LABELS: Record<string, string> = {
|
||||
all: '通用',
|
||||
prod: '生产',
|
||||
test: '测试',
|
||||
};
|
||||
|
||||
const ENV_COLORS: Record<string, string> = {
|
||||
all: 'default',
|
||||
prod: 'red',
|
||||
test: 'blue',
|
||||
};
|
||||
|
||||
const SystemSecrets: React.FC = () => {
|
||||
const [secrets, setSecrets] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingSecret, setEditingSecret] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filterGroup, setFilterGroup] = useState<string | undefined>();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (filterGroup) params.groupName = filterGroup;
|
||||
const res = await getSecrets(params);
|
||||
setSecrets(res.data || []);
|
||||
} catch {
|
||||
message.error('获取密钥列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [filterGroup]);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSecret(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingSecret(record);
|
||||
form.setFieldsValue({
|
||||
secretKey: record.secretKey,
|
||||
description: record.description,
|
||||
groupName: record.groupName,
|
||||
env: record.env,
|
||||
secretValue: '', // 编辑时不回显原值
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingSecret) {
|
||||
// 更新时如果没有填新值则不更新
|
||||
const updateData: any = {
|
||||
description: values.description,
|
||||
groupName: values.groupName,
|
||||
env: values.env,
|
||||
};
|
||||
if (values.secretValue) {
|
||||
updateData.secretValue = values.secretValue;
|
||||
}
|
||||
await updateSecret(editingSecret.id, updateData);
|
||||
message.success('密钥已更新');
|
||||
} else {
|
||||
await createSecret(values);
|
||||
message.success('密钥已创建');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteSecret(id);
|
||||
message.success('密钥已删除');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '密钥名',
|
||||
dataIndex: 'secretKey',
|
||||
key: 'secretKey',
|
||||
width: 220,
|
||||
render: (text: string) => <code style={{ fontSize: 13 }}>{text}</code>,
|
||||
},
|
||||
{
|
||||
title: '当前值',
|
||||
dataIndex: 'maskedValue',
|
||||
key: 'maskedValue',
|
||||
width: 140,
|
||||
render: (text: string) => <span style={{ color: '#999' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '说明',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '分组',
|
||||
dataIndex: 'groupName',
|
||||
key: 'groupName',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={GROUP_COLORS[text] || 'default'}>
|
||||
{GROUP_LABELS[text] || text}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '环境',
|
||||
dataIndex: 'env',
|
||||
key: 'env',
|
||||
width: 80,
|
||||
render: (text: string) => (
|
||||
<Tag color={ENV_COLORS[text]}>{ENV_LABELS[text]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 170,
|
||||
render: (text: string) => text?.replace('T', ' ').slice(0, 19),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => (
|
||||
<span>
|
||||
<Button type="link" size="small" onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此密钥?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
placeholder="按分组筛选"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={filterGroup}
|
||||
onChange={setFilterGroup}
|
||||
>
|
||||
{Object.entries(GROUP_LABELS).map(([key, label]) => (
|
||||
<Select.Option key={key} value={key}>{label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
添加密钥
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={secrets}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingSecret ? '编辑密钥' : '添加密钥'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="secretKey"
|
||||
label="密钥名"
|
||||
rules={[{ required: true, message: '请输入密钥名' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="如 WECHAT_SECRET"
|
||||
disabled={!!editingSecret}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="secretValue"
|
||||
label="密钥值"
|
||||
rules={editingSecret ? [] : [{ required: true, message: '请输入密钥值' }]}
|
||||
extra={editingSecret ? '留空则不修改原值' : ''}
|
||||
>
|
||||
<Input.Password placeholder="输入密钥值(加密存储)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input placeholder="如:微信小程序 Secret" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="groupName"
|
||||
label="分组"
|
||||
style={{ flex: 1 }}
|
||||
initialValue="default"
|
||||
>
|
||||
<Select>
|
||||
{Object.entries(GROUP_LABELS).map(([key, label]) => (
|
||||
<Select.Option key={key} value={key}>{label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="env"
|
||||
label="适用环境"
|
||||
style={{ flex: 1 }}
|
||||
initialValue="all"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="all">通用</Select.Option>
|
||||
<Select.Option value="prod">仅生产</Select.Option>
|
||||
<Select.Option value="test">仅测试</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSecrets;
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user