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
+2
View File
@@ -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>
+31
View File
@@ -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;