feat: 重构仓位轮训逻辑并修复多语言配置

主要改动:
1. 创建独立的 PositionCheckService 服务
   - 将仓位检查逻辑从 PositionPushService 中分离
   - 实现待赎回仓位检查和未卖出订单检查
   - 实现订单状态更新逻辑(FIFO策略)

2. 修改 PositionPushService
   - 后端启动时自动启动轮训任务
   - 每次轮训后推送全量数据给所有订阅的客户端
   - 调用 PositionCheckService 进行仓位检查

3. 自动赎回功能调整
   - 将自动赎回从跟单配置级别移到系统级别
   - 添加系统级自动赎回配置管理
   - 支持 Builder API Key 配置检查

4. 多语言配置修复
   - 修复 PositionCheckService 中的硬编码消息
   - 使用 MessageSource 获取多语言文本
   - 添加自动赎回和 Builder API Key 相关的多语言资源

5. 数据库迁移
   - V8: 添加跟单配置名称字段
   - V9: 将自动赎回配置移到系统配置表

6. 前端更新
   - 添加系统级自动赎回配置界面
   - 更新跟单配置管理界面
   - 添加 Builder API Key 配置界面
This commit is contained in:
WrBug
2025-12-07 02:06:19 +08:00
parent bfbbbdd1de
commit cb167d442f
31 changed files with 1544 additions and 121 deletions
+18 -11
View File
@@ -25,11 +25,11 @@ const BuilderApiKeySettings: React.FC = () => {
if (response.data.code === 0 && response.data.data) {
const config = response.data.data
setBuilderApiKeyConfig(config)
// 预填充字段(如果已配置,显示占位符
// 如果已配置,输入框留空(不显示***
builderApiKeyForm.setFieldsValue({
builderApiKey: config.builderApiKeyConfigured ? '***' : '',
builderSecret: config.builderSecretConfigured ? '***' : '',
builderPassphrase: config.builderPassphraseConfigured ? '***' : '',
builderApiKey: '',
builderSecret: '',
builderPassphrase: '',
})
} else {
message.error(response.data.msg || t('builderApiKey.getFailed'))
@@ -42,16 +42,23 @@ const BuilderApiKeySettings: React.FC = () => {
const handleBuilderApiKeySubmit = async (values: BuilderApiKeyUpdateRequest) => {
setBuilderApiKeyLoading(true)
try {
// 如果值是 '***',表示已配置但未修改,不发送
// 只发送非空字段(如果字段为空且已配置,表示不修改,不发送该字段)
const updateData: BuilderApiKeyUpdateRequest = {}
if (values.builderApiKey && values.builderApiKey !== '***') {
updateData.builderApiKey = values.builderApiKey
if (values.builderApiKey && values.builderApiKey.trim()) {
updateData.builderApiKey = values.builderApiKey.trim()
}
if (values.builderSecret && values.builderSecret !== '***') {
updateData.builderSecret = values.builderSecret
if (values.builderSecret && values.builderSecret.trim()) {
updateData.builderSecret = values.builderSecret.trim()
}
if (values.builderPassphrase && values.builderPassphrase !== '***') {
updateData.builderPassphrase = values.builderPassphrase
if (values.builderPassphrase && values.builderPassphrase.trim()) {
updateData.builderPassphrase = values.builderPassphrase.trim()
}
// 如果所有字段都为空,提示用户
if (!updateData.builderApiKey && !updateData.builderSecret && !updateData.builderPassphrase) {
message.warning(t('builderApiKey.noChanges') || '没有需要更新的字段')
setBuilderApiKeyLoading(false)
return
}
const response = await apiService.systemConfig.updateBuilderApiKey(updateData)
+54 -3
View File
@@ -22,10 +22,31 @@ const CopyTradingAdd: React.FC = () => {
const [templateModalVisible, setTemplateModalVisible] = useState(false)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
// 生成默认配置名
const generateDefaultConfigName = (): string => {
const now = new Date()
const dateStr = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\//g, '-')
const timeStr = now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
return `跟单配置-${dateStr}-${timeStr}`
}
useEffect(() => {
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
}, [])
const fetchLeaders = async () => {
@@ -114,7 +135,9 @@ const CopyTradingAdd: React.FC = () => {
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString()
maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim(),
pushFailedOrders: values.pushFailedOrders ?? false
}
const response = await apiService.copyTrading.create(request)
@@ -163,10 +186,26 @@ const CopyTradingAdd: React.FC = () => {
useWebSocket: true,
websocketReconnectInterval: 5000,
websocketMaxRetries: 10,
supportSell: true
supportSell: true,
pushFailedOrders: false
}}
>
{/* 基础信息 */}
<Form.Item
label={t('copyTradingAdd.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingAdd.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingAdd.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingAdd.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingAdd.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || '选择钱包'}
name="accountId"
@@ -444,7 +483,9 @@ const CopyTradingAdd: React.FC = () => {
</Input.Group>
</Form.Item>
{/* 跟单卖出 - 表单最底部 */}
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
<Form.Item
label={t('copyTradingAdd.supportSell') || '跟单卖出'}
name="supportSell"
@@ -454,6 +495,16 @@ const CopyTradingAdd: React.FC = () => {
<Switch />
</Form.Item>
{/* 推送失败订单 */}
<Form.Item
label={t('copyTradingAdd.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingAdd.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
+34 -3
View File
@@ -58,7 +58,9 @@ const CopyTradingEdit: React.FC = () => {
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minOrderbookDepth: found.minOrderbookDepth ? parseFloat(found.minOrderbookDepth) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false
})
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
@@ -122,7 +124,9 @@ const CopyTradingEdit: React.FC = () => {
maxSpread: values.maxSpread?.toString(),
minOrderbookDepth: values.minOrderbookDepth?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString()
maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
}
const response = await apiService.copyTrading.update(request)
@@ -172,6 +176,21 @@ const CopyTradingEdit: React.FC = () => {
onFinish={handleSubmit}
>
{/* 基础信息(只读) */}
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
name="accountId"
@@ -433,7 +452,9 @@ const CopyTradingEdit: React.FC = () => {
</Input.Group>
</Form.Item>
{/* 跟单卖出 - 表单最底部 */}
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
<Form.Item
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
name="supportSell"
@@ -443,6 +464,16 @@ const CopyTradingEdit: React.FC = () => {
<Switch />
</Form.Item>
{/* 推送失败订单 */}
<Form.Item
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
+18 -1
View File
@@ -146,6 +146,16 @@ const CopyTradingList: React.FC = () => {
}
const columns = [
{
title: t('copyTradingList.configName') || '配置名',
key: 'configName',
width: isMobile ? 100 : 150,
render: (_: any, record: CopyTrading) => (
<div style={{ fontSize: isMobile ? 13 : 14, fontWeight: 500 }}>
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
</div>
)
},
{
title: t('copyTradingList.wallet') || '钱包',
key: 'account',
@@ -454,10 +464,17 @@ const CopyTradingList: React.FC = () => {
{/* 基本信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '16px',
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
</div>
<div style={{
fontSize: '14px',
marginBottom: '8px',
color: '#666'
}}>
{record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x`
+98 -1
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Badge, Spin, Row, Col } from 'antd'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined } from '@ant-design/icons'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, SettingOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
const { Title, Text } = Typography
@@ -35,18 +36,23 @@ interface ApiHealthStatus {
}
const SystemSettings: React.FC = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [form] = Form.useForm()
const [autoRedeemForm] = Form.useForm()
const [loading, setLoading] = useState(false)
const [checking, setChecking] = useState(false)
const [checkResult, setCheckResult] = useState<ProxyCheckResponse | null>(null)
const [currentConfig, setCurrentConfig] = useState<ProxyConfig | null>(null)
const [apiHealthStatus, setApiHealthStatus] = useState<ApiHealthStatus[]>([])
const [checkingApiHealth, setCheckingApiHealth] = useState(false)
const [autoRedeemLoading, setAutoRedeemLoading] = useState(false)
const [builderApiKeyConfigured, setBuilderApiKeyConfigured] = useState<boolean>(false)
useEffect(() => {
fetchConfig()
checkApiHealth()
fetchSystemConfig()
}, [])
const fetchConfig = async () => {
@@ -161,6 +167,38 @@ const SystemSettings: React.FC = () => {
}
}
const fetchSystemConfig = async () => {
try {
const response = await apiService.systemConfig.get()
if (response.data.code === 0 && response.data.data) {
const config = response.data.data
setBuilderApiKeyConfigured(config.builderApiKeyConfigured)
autoRedeemForm.setFieldsValue({
autoRedeem: config.autoRedeem
})
}
} catch (error: any) {
console.error('获取系统配置失败:', error)
}
}
const handleAutoRedeemSubmit = async (values: { autoRedeem: boolean }) => {
setAutoRedeemLoading(true)
try {
const response = await apiService.systemConfig.updateAutoRedeem({ enabled: values.autoRedeem })
if (response.data.code === 0) {
message.success(t('systemSettings.autoRedeem.saveSuccess') || '自动赎回配置已更新')
fetchSystemConfig()
} else {
message.error(response.data.msg || t('systemSettings.autoRedeem.saveFailed') || '更新自动赎回配置失败')
}
} catch (error: any) {
message.error(error.message || t('systemSettings.autoRedeem.saveFailed') || '更新自动赎回配置失败')
} finally {
setAutoRedeemLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
@@ -389,6 +427,65 @@ const SystemSettings: React.FC = () => {
/>
)}
</Card>
<Card
title={
<Space>
<SettingOutlined />
<span>{t('systemSettings.autoRedeem.title') || '自动赎回配置'}</span>
</Space>
}
style={{ marginBottom: '16px' }}
>
<Form
form={autoRedeemForm}
layout="vertical"
onFinish={handleAutoRedeemSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item
label={t('systemSettings.autoRedeem.label') || '自动赎回'}
name="autoRedeem"
tooltip={t('systemSettings.autoRedeem.tooltip') || '开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效'}
valuePropName="checked"
>
<Switch />
</Form.Item>
{!builderApiKeyConfigured && (
<Alert
message={t('systemSettings.autoRedeem.builderApiKeyNotConfigured') || 'Builder API Key 未配置'}
description={
<span>
{t('systemSettings.autoRedeem.builderApiKeyNotConfiguredDesc') || '自动赎回功能需要配置 Builder API Key 才能生效。'}
<Button
type="link"
size="small"
onClick={() => window.location.href = '/system-settings/builder-api-key'}
style={{ padding: 0, marginLeft: '8px' }}
>
{t('systemSettings.autoRedeem.goToConfigure') || '前往配置'}
</Button>
</span>
}
type="warning"
showIcon
style={{ marginBottom: '16px' }}
/>
)}
<Form.Item>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={autoRedeemLoading}
>
{t('common.save') || '保存配置'}
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}