feat: 实现RPC节点管理功能

- 后端功能:
  - 添加RPC节点配置管理(增删改查、优先级调整)
  - 实现节点健康检查和自动故障转移
  - 修复Retrofit baseUrl处理问题(使用拦截器动态替换URL)
  - 添加RPC节点可用性验证(创建前验证)
  - 默认节点作为兜底,不返回给前端,始终排在最后
  - API健康检查使用动态获取的可用节点

- 前端功能:
  - 添加RPC节点设置页面
  - 支持添加、删除、检查节点
  - 支持调整节点优先级
  - 完整的多语言支持(中文简体/繁体、英文)
  - 添加菜单项多语言key

- 数据库:
  - 添加RPC节点配置表迁移脚本
This commit is contained in:
WrBug
2025-12-25 23:58:50 +08:00
parent 7bc059ec34
commit 1580b60d24
20 changed files with 1856 additions and 70 deletions
+172
View File
@@ -0,0 +1,172 @@
import { useState } from 'react'
import { Modal, Form, Input, Select, message, Space } from 'antd'
import { LinkOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { RpcNodeAddRequest } from '../types'
import { useTranslation } from 'react-i18next'
interface AddRpcNodeModalProps {
visible: boolean
onCancel: () => void
onSuccess: () => void
}
const { Option } = Select
const AddRpcNodeModal: React.FC<AddRpcNodeModalProps> = ({ visible, onCancel, onSuccess }) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const [selectedProvider, setSelectedProvider] = useState<string>('CUSTOM')
const [validating, setValidating] = useState(false)
const providerOptions = [
{ value: 'ALCHEMY', label: t('rpcNodeSettings.providerAlchemy'), url: 'https://dashboard.alchemy.com/' },
{ value: 'INFURA', label: t('rpcNodeSettings.providerInfura'), url: 'https://infura.io/' },
{ value: 'QUICKNODE', label: t('rpcNodeSettings.providerQuickNode'), url: 'https://www.quicknode.com/' },
{ value: 'CHAINSTACK', label: t('rpcNodeSettings.providerChainstack'), url: 'https://chainstack.com/' },
{ value: 'GETBLOCK', label: t('rpcNodeSettings.providerGetBlock'), url: 'https://getblock.io/' },
{ value: 'CUSTOM', label: t('rpcNodeSettings.customNode'), url: '' }
]
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setValidating(true)
const request: RpcNodeAddRequest = {
providerType: values.providerType,
name: values.name,
apiKey: values.apiKey,
httpUrl: values.httpUrl,
wsUrl: values.wsUrl
}
// 先验证节点
const validateResponse = await apiService.rpcNodes.validate(request)
if (validateResponse.data.code === 0 && validateResponse.data.data) {
const result = validateResponse.data.data
if (!result.valid) {
message.error(`${t('rpcNodeSettings.validateFailed')} ${result.message}`)
setValidating(false)
return
}
// 验证通过,添加节点
const addResponse = await apiService.rpcNodes.add(request)
if (addResponse.data.code === 0) {
message.success(t('rpcNodeSettings.addSuccess'))
form.resetFields()
setSelectedProvider('CUSTOM')
onSuccess()
} else {
message.error(addResponse.data.msg || t('rpcNodeSettings.addFailed'))
}
} else {
message.error(validateResponse.data.msg || t('rpcNodeSettings.validateError'))
}
} catch (error: any) {
if (!error.errorFields) {
message.error(error.message || t('rpcNodeSettings.operationFailed'))
}
} finally {
setValidating(false)
}
}
const handleCancel = () => {
form.resetFields()
setSelectedProvider('CUSTOM')
onCancel()
}
const currentProvider = providerOptions.find(p => p.value === selectedProvider)
return (
<Modal
title={t('rpcNodeSettings.addNodeTitle')}
open={visible}
onOk={handleSubmit}
onCancel={handleCancel}
width={600}
confirmLoading={validating}
okText={t('rpcNodeSettings.validateAndAdd')}
cancelText={t('common.cancel')}
>
<Form
form={form}
layout="vertical"
initialValues={{ providerType: 'CUSTOM' }}
>
<Form.Item
label={t('rpcNodeSettings.providerTypeLabel')}
name="providerType"
rules={[{ required: true, message: t('rpcNodeSettings.providerTypeRequired') }]}
>
<Select onChange={setSelectedProvider}>
{providerOptions.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t('rpcNodeSettings.nodeNameLabel')}
name="name"
rules={[{ required: true, message: t('rpcNodeSettings.nodeNameRequired') }]}
>
<Input placeholder={t('rpcNodeSettings.nodeNamePlaceholder')} />
</Form.Item>
{selectedProvider !== 'CUSTOM' && (
<Form.Item
label={
<Space>
<span>{t('rpcNodeSettings.apiKeyLabel')}</span>
{currentProvider?.url && (
<a href={currentProvider.url} target="_blank" rel="noopener noreferrer">
<LinkOutlined /> {t('rpcNodeSettings.getApiKey')}
</a>
)}
</Space>
}
name="apiKey"
rules={[{ required: true, message: t('rpcNodeSettings.apiKeyRequired') }]}
>
<Input.Password
placeholder={t('rpcNodeSettings.apiKeyPlaceholder')}
autoComplete="off"
/>
</Form.Item>
)}
{selectedProvider === 'CUSTOM' && (
<>
<Form.Item
label={t('rpcNodeSettings.httpUrlLabel')}
name="httpUrl"
rules={[
{ required: true, message: t('rpcNodeSettings.httpUrlRequired') },
{ type: 'url', message: t('rpcNodeSettings.httpUrlInvalid') }
]}
>
<Input placeholder={t('rpcNodeSettings.httpUrlPlaceholder')} />
</Form.Item>
<Form.Item
label={t('rpcNodeSettings.wsUrlLabel')}
name="wsUrl"
rules={[{ type: 'url', message: t('rpcNodeSettings.wsUrlInvalid') }]}
>
<Input placeholder={t('rpcNodeSettings.wsUrlPlaceholder')} />
</Form.Item>
</>
)}
</Form>
</Modal>
)
}
export default AddRpcNodeModal
+6 -1
View File
@@ -19,7 +19,7 @@ import {
TwitterOutlined,
CheckCircleOutlined,
SendOutlined,
NotificationOutlined
ApiOutlined, NotificationOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -131,6 +131,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <SettingOutlined />,
label: t('menu.systemOverview') || '通用设置'
},
{
key: '/system-settings/rpc-nodes',
icon: <ApiOutlined />,
label: t('menu.rpcNodes') || 'RPC节点管理'
},
{
key: '/system-settings/api-health',
icon: <CheckCircleOutlined />,