fix: 修复未匹配订单更新逻辑和前端API类型定义

- 修复 PositionCheckService 中未匹配订单更新逻辑,当仓位数量大于等于订单数量总和时也能正确处理
- 修复前端 API 类型定义,添加 remark 和 website 字段支持
- 添加 Leader 备注和网站字段支持
This commit is contained in:
WrBug
2025-12-07 05:07:58 +08:00
parent cb167d442f
commit 5c136ab0f4
22 changed files with 1641 additions and 941 deletions
-239
View File
@@ -1,239 +0,0 @@
# 后端实现总结
## 已完成的工作
### 1. 项目基础结构 ✅
- ✅ 创建了完整的后端项目目录结构
- ✅ 配置了 `application.properties` 配置文件
- ✅ 创建了 Spring Boot 主应用类
- ✅ 配置了 Gradle 构建文件
### 2. 工具类 ✅
`/Users/wrbug/hype-quant/quant/src/main/kotlin/com/hypequant/util` 拷贝并适配了以下工具类:
-`SafeConvertExt.kt` - 安全类型转换扩展函数
-`MathExt.kt` - BigDecimal 数学运算扩展函数
-`SystemExt.kt` - 系统环境变量工具
-`OkHttpExt.kt` - OkHttp 客户端扩展函数
-`CategoryValidator.kt` - 分类验证工具类(新增)
### 3. Polymarket API 封装 ✅
#### 3.1 CLOB API ✅
- ✅ 定义了 `PolymarketClobApi` 接口
- ✅ 实现了 `PolymarketClobService` 服务封装
- ✅ 支持的功能:
- 获取订单簿
- 获取价格信息
- 获取中间价
- 创建订单
- 获取活跃订单
- 取消订单
- 获取交易记录
#### 3.2 Gamma API ✅
- ✅ 定义了 `PolymarketGammaApi` 接口
- ✅ 实现了 `PolymarketGammaService` 服务封装
- ✅ 支持的功能:
- 获取市场列表(带分类验证)
- 获取市场详情
- 搜索市场(带分类验证)
- 获取事件列表(带分类验证)
- 获取体育市场
- 获取加密货币市场
### 4. WebSocket 转发服务 ✅
- ✅ 实现了 `PolymarketWebSocketHandler` - WebSocket 处理器
- ✅ 实现了 `PolymarketWebSocketClient` - Polymarket RTDS 客户端
- ✅ 配置了 `WebSocketConfig` - WebSocket 配置
- ✅ 支持双向消息转发:
- 前端 → 后端 → Polymarket RTDS
- Polymarket RTDS → 后端 → 前端
### 5. 统一响应格式 ✅
- ✅ 创建了 `ApiResponse` 统一响应格式
- ✅ 提供了便捷的响应创建方法:
- `success()` - 成功响应
- `error()` - 错误响应
- `paramError()` - 参数错误
- `authError()` - 认证错误
- `notFound()` - 资源不存在
- `businessError()` - 业务逻辑错误
- `serverError()` - 服务器错误
### 6. Controller 实现 ✅
- ✅ 实现了 `MarketController` - 市场相关接口
- ✅ 提供的接口:
- `POST /api/markets/list` - 获取市场列表
- `POST /api/markets/detail` - 获取市场详情
- `POST /api/markets/search` - 搜索市场
- `POST /api/markets/sports` - 获取体育市场
- `POST /api/markets/crypto` - 获取加密货币市场
### 7. 配置类 ✅
-`RetrofitConfig` - Retrofit 和 API 客户端配置
-`WebSocketConfig` - WebSocket 配置
## 项目结构
```
backend/
├── src/main/kotlin/com/wrbug/polymarketbot/
│ ├── api/ # API 接口定义
│ │ ├── PolymarketClobApi.kt
│ │ └── PolymarketGammaApi.kt
│ ├── config/ # 配置类
│ │ ├── RetrofitConfig.kt
│ │ └── WebSocketConfig.kt
│ ├── controller/ # 控制器
│ │ └── MarketController.kt
│ ├── dto/ # 数据传输对象
│ │ └── ApiResponse.kt
│ ├── service/ # 服务层
│ │ ├── PolymarketClobService.kt
│ │ └── PolymarketGammaService.kt
│ ├── util/ # 工具类
│ │ ├── SafeConvertExt.kt
│ │ ├── MathExt.kt
│ │ ├── SystemExt.kt
│ │ ├── OkHttpExt.kt
│ │ └── CategoryValidator.kt
│ ├── websocket/ # WebSocket 处理
│ │ ├── PolymarketWebSocketHandler.kt
│ │ └── PolymarketWebSocketClient.kt
│ └── PolymarketBotApplication.kt # 主应用类
├── src/main/resources/
│ └── application.properties # 配置文件
├── build.gradle.kts # Gradle 构建配置
└── settings.gradle.kts # Gradle 设置
```
## 关键特性
### 1. 分类验证
所有涉及分类的接口都会验证分类参数,仅支持 `sports``crypto`
```kotlin
// 自动验证分类
CategoryValidator.validate(category)
```
### 2. 统一错误处理
所有接口统一返回 `ApiResponse` 格式,包含:
- `code`: 错误码
- `data`: 响应数据
- `msg`: 错误消息
### 3. WebSocket 转发
前端通过 `ws://localhost:8000/ws/polymarket` 连接,后端自动转发到 Polymarket RTDS。
### 4. 异步支持
所有 API 调用使用 Kotlin Coroutines 的 `suspend` 函数,Controller 层使用 `runBlocking` 调用。
## 依赖说明
主要依赖:
- Spring Boot 3.2.0
- Kotlin 1.9.20
- Retrofit 2.9.0
- OkHttp 4.12.0
- Java-WebSocket 1.5.4
- MySQL Connector 8.2.0
## 配置说明
### 环境变量
- `DB_USERNAME`: 数据库用户名
- `DB_PASSWORD`: 数据库密码
- `SERVER_PORT`: 服务器端口(默认 8000
- `POLYMARKET_API_KEY`: Polymarket API Key(可选)
### application.properties
```properties
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
polymarket.api-key=${POLYMARKET_API_KEY:}
```
## 使用示例
### 1. 获取市场列表
```bash
curl -X POST http://localhost:8000/api/markets/list \
-H "Content-Type: application/json" \
-d '{
"category": "sports",
"active": true,
"limit": 20
}'
```
### 2. WebSocket 连接
```javascript
const ws = new WebSocket('ws://localhost:8000/ws/polymarket');
ws.onopen = () => {
// 订阅市场数据
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'market',
market: 'market_id'
}));
};
ws.onmessage = (event) => {
console.log('收到消息:', event.data);
};
```
## 下一步工作
1. **数据库实体和 Repository**
- 创建 Market、Order、Trade 等实体类
- 实现对应的 Repository 接口
- 创建 Flyway 迁移脚本
2. **数据同步服务**
- 实现市场数据同步任务
- 实现价格更新任务
- 实现订单状态同步
3. **订单管理 Controller**
- 实现订单创建接口
- 实现订单查询接口
- 实现订单取消接口
4. **测试**
- 单元测试
- 集成测试
- API 测试
5. **文档**
- API 文档
- 部署文档
## 注意事项
1. **分类限制**: 严格限制只支持 `sports``crypto` 两个分类
2. **WebSocket**: 生产环境需要配置正确的 CORS 策略
3. **API Key**: 某些接口需要配置 Polymarket API Key
4. **错误处理**: 所有异常都会被捕获并返回统一格式的错误响应
@@ -424,12 +424,12 @@ class AccountController(
)
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.BUSINESS_ERROR,
e.message,
messageSource
)
)
ApiResponse.error(
ErrorCode.BUSINESS_ERROR,
e.message,
messageSource
)
)
}
}
@@ -36,7 +36,7 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val autoRedeem: Boolean = true // 自动赎回(系统级别配置,默认开启)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
)
/**
@@ -6,7 +6,9 @@ package com.wrbug.polymarketbot.dto
data class LeaderAddRequest(
val leaderAddress: String,
val leaderName: String? = null,
val category: String? = null // sports 或 crypto
val category: String? = null, // sports 或 crypto
val remark: String? = null, // Leader 备注(可选)
val website: String? = null // Leader 网站(可选)
)
/**
@@ -15,7 +17,9 @@ data class LeaderAddRequest(
data class LeaderUpdateRequest(
val leaderId: Long,
val leaderName: String? = null,
val category: String? = null
val category: String? = null,
val remark: String? = null, // Leader 备注(可选)
val website: String? = null // Leader 网站(可选)
)
/**
@@ -40,6 +44,8 @@ data class LeaderDto(
val leaderAddress: String,
val leaderName: String?,
val category: String?,
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
@@ -22,6 +22,12 @@ data class Leader(
@Column(name = "category", length = 20)
val category: String? = null, // sports 或 cryptonull 表示不筛选
@Column(name = "remark", columnDefinition = "TEXT")
val remark: String? = null, // Leader 备注(可选)
@Column(name = "website", length = 500)
val website: String? = null, // Leader 网站(可选)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -49,10 +49,19 @@ class LeaderService(
}
// 5. 创建 Leader
// 如果 website 为空,自动设置为 polymarket profile 页
val website = if (request.website.isNullOrBlank()) {
"https://polymarket.com/profile/${request.leaderAddress}"
} else {
request.website
}
val leader = Leader(
leaderAddress = request.leaderAddress,
leaderName = request.leaderName,
category = request.category
leaderName = request.leaderName?.takeIf { it.isNotBlank() },
category = request.category,
remark = request.remark?.takeIf { it.isNotBlank() },
website = website
)
val saved = leaderRepository.save(leader)
@@ -78,9 +87,19 @@ class LeaderService(
CategoryValidator.validate(request.category)
}
// 处理更新逻辑:如果请求中的字段为 null 或空字符串,都设置为 null
// 如果 website 为空,自动设置为 polymarket profile 页
val website = if (request.website.isNullOrBlank()) {
"https://polymarket.com/profile/${leader.leaderAddress}"
} else {
request.website
}
val updated = leader.copy(
leaderName = request.leaderName ?: leader.leaderName,
category = request.category ?: leader.category,
leaderName = request.leaderName?.takeIf { it.isNotBlank() },
category = request.category,
remark = request.remark?.takeIf { it.isNotBlank() },
website = website,
updatedAt = System.currentTimeMillis()
)
@@ -175,6 +194,8 @@ class LeaderService(
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
category = leader.category,
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
@@ -3,13 +3,19 @@ package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.AccountPositionDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.multi
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.stereotype.Service
@@ -19,12 +25,16 @@ import java.util.concurrent.ConcurrentHashMap
/**
* 仓位检查服务
* 负责检查待赎回仓位和未卖出订单,并执行相应的处理逻辑
* 订阅 PositionPollingService 的事件,处理仓位检查逻辑
*/
@Service
class PositionCheckService(
private val positionPollingService: PositionPollingService,
private val accountService: AccountService,
private val copyTradingRepository: CopyTradingRepository,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val systemConfigService: SystemConfigService,
private val relayClientService: RelayClientService,
private val telegramNotificationService: TelegramNotificationService?,
@@ -34,8 +44,9 @@ class PositionCheckService(
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
// 协程作用域,用于缓存清理任务
// 协程作用域,用于订阅事件和缓存清理任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var subscriptionJob: Job? = null
// 记录已发送通知的仓位(避免重复推送)
private val notifiedRedeemablePositions = ConcurrentHashMap<String, Long>() // "accountId_marketId_outcomeIndex" -> lastNotificationTime
@@ -43,14 +54,60 @@ class PositionCheckService(
// 记录已发送提示的配置(避免重复推送)
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(启动缓存清理任务)
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
*/
@PostConstruct
fun init() {
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
startSubscription()
startCacheCleanup()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
subscriptionJob?.cancel()
subscriptionJob = null
}
scope.cancel()
}
/**
* 启动订阅任务(订阅 PositionPollingService 的事件)
*/
private fun startSubscription() {
synchronized(lock) {
// 如果已经有订阅任务在运行,先取消
subscriptionJob?.cancel()
// 启动新的订阅任务(使用专门的线程,避免阻塞)
subscriptionJob = scope.launch(Dispatchers.IO) {
try {
// 订阅仓位轮训事件
positionPollingService.subscribe { positions ->
// 在协程中处理仓位检查逻辑,避免阻塞
scope.launch(Dispatchers.IO) {
try {
checkPositions(positions.currentPositions)
} catch (e: Exception) {
logger.error("处理仓位检查事件失败: ${e.message}", e)
}
}
}
} catch (e: Exception) {
logger.error("订阅仓位轮训事件失败: ${e.message}", e)
}
}
}
}
/**
* 启动缓存清理任务(定期清理过期的通知记录)
*/
@@ -118,16 +175,55 @@ class PositionCheckService(
/**
* 逻辑1:处理待赎回仓位
* 如果有待赎回的仓位,检查是否开启了自动赎回,是否有相同仓位的订单(未卖出的订单)
* 如果有的话,在仓位赎回成功后以该订单卖出逻辑更新所有订单状态(未卖出)
* 如果未开启自动赎回,则发送tg通知,并且记录(内存缓存)该仓位,避免重复发送
* 按照以下逻辑处理:
* 1. 无待赎回仓位:跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
*/
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
try {
// 1. 无待赎回仓位:跳过
if (redeemablePositions.isEmpty()) {
return
}
// 检查系统级别的自动赎回配置
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
// 按账户分组
// 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
if (!autoRedeemEnabled || !apiKeyConfigured) {
// 按账户分组发送通知
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
for ((accountId, positions) in positionsByAccount) {
for (position in positions) {
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
// 检查是否在最近2小时内已发送过提示(避免频繁推送)
val lastNotification = notifiedRedeemablePositions[positionKey]
val now = System.currentTimeMillis()
if (lastNotification == null || (now - lastNotification) >= 7200000) { // 2小时
if (!autoRedeemEnabled) {
// 自动赎回未开启:直接发送通知,不需要查找跟单配置
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
} else {
// API Key 未配置:需要查找跟单配置来发送通知
val copyTradings = copyTradingRepository.findByAccountId(accountId)
.filter { it.enabled }
for (copyTrading in copyTradings) {
checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position))
}
}
notifiedRedeemablePositions[positionKey] = now
}
}
}
return // 未配置时直接返回,不进行后续处理
}
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
// 先执行赎回,赎回成功后再查找订单并更新订单状态
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
for ((accountId, positions) in positionsByAccount) {
@@ -139,90 +235,52 @@ class PositionCheckService(
continue
}
// 收集所有有未卖出订单的仓位,按账户分组一次性处理
val positionsWithOrders = mutableListOf<Pair<AccountPositionDto, List<CopyOrderTracking>>>()
val positionsWithoutOrders = mutableListOf<AccountPositionDto>()
for (position in positions) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
}
if (unmatchedOrders.isNotEmpty()) {
positionsWithOrders.add(Pair(position, unmatchedOrders))
} else {
positionsWithoutOrders.add(position)
}
}
// 处理有未卖出订单的仓位
if (positionsWithOrders.isNotEmpty()) {
if (autoRedeemEnabled && relayClientService.isBuilderApiKeyConfigured()) {
// 开启自动赎回且已配置 API Key,执行自动赎回(一次性赎回该账户的所有可赎回仓位)
val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest(
positions = positionsWithOrders.map { (position, _) ->
com.wrbug.polymarketbot.dto.AccountRedeemPositionItem(
accountId = accountId,
marketId = position.marketId,
outcomeIndex = position.outcomeIndex ?: 0,
side = position.side
)
}
// 先执行赎回(不查找订单)
val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest(
positions = positions.map { position ->
com.wrbug.polymarketbot.dto.AccountRedeemPositionItem(
accountId = accountId,
marketId = position.marketId,
outcomeIndex = position.outcomeIndex ?: 0,
side = position.side
)
}
)
val redeemResult = accountService.redeemPositions(redeemRequest)
redeemResult.fold(
onSuccess = { response ->
logger.info("自动赎回成功: accountId=$accountId, redeemedCount=${positions.size}, totalValue=${response.totalRedeemedValue}")
val redeemResult = accountService.redeemPositions(redeemRequest)
redeemResult.fold(
onSuccess = { response ->
logger.info("自动赎回成功: accountId=$accountId, redeemedCount=${positionsWithOrders.size}, totalValue=${response.totalRedeemedValue}")
// 在仓位赎回成功后,以该订单卖出逻辑更新所有订单状态(未卖出)
for ((position, orders) in positionsWithOrders) {
updateOrdersAsSoldAfterRedeem(orders, position)
}
},
onFailure = { e ->
logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e)
}
)
} else {
// 未开启自动赎回或未配置 API Key,发送通知
for ((position, _) in positionsWithOrders) {
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
if (!autoRedeemEnabled) {
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
} else {
// API Key 未配置
for (copyTrading in copyTradings) {
checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position))
// 赎回成功后,再查找订单并更新订单状态
for (position in positions) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
}
// 如果有未卖出订单,更新订单状态
if (unmatchedOrders.isNotEmpty()) {
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
if (copyTradingId != null) {
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
}
}
// 记录已发送通知的仓位(避免重复发送)
notifiedRedeemablePositions[positionKey] = System.currentTimeMillis()
}
},
onFailure = { e ->
logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e)
}
}
// 处理没有未卖出订单的仓位(如果未开启自动赎回,发送通知)
if (positionsWithoutOrders.isNotEmpty() && !autoRedeemEnabled) {
for (position in positionsWithoutOrders) {
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
// 检查是否在最近2小时内已发送过提示(避免频繁推送)
val lastNotification = notifiedRedeemablePositions[positionKey]
val now = System.currentTimeMillis()
if (lastNotification == null || (now - lastNotification) >= 7200000) { // 2小时
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
notifiedRedeemablePositions[positionKey] = now
}
}
}
)
}
} catch (e: Exception) {
logger.error("处理待赎回仓位异常: ${e.message}", e)
@@ -274,17 +332,15 @@ class PositionCheckService(
if (position == null) {
// 仓位不存在,更新所有订单状态为已卖出
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(orders, currentPrice)
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
} else {
// 有仓位,检查仓位数量是否小于所有未卖出订单数量总和
// 有仓位,按订单下单顺序(FIFO)更新状态
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
val positionQuantity = position.quantity.toSafeBigDecimal()
if (positionQuantity < totalUnmatchedQuantity) {
// 仓位数量小于订单数量总和,按订单下单顺序(FIFO)更新状态
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice)
}
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
}
}
}
@@ -320,11 +376,12 @@ class PositionCheckService(
*/
private suspend fun updateOrdersAsSoldAfterRedeem(
orders: List<CopyOrderTracking>,
position: AccountPositionDto
position: AccountPositionDto,
copyTradingId: Long
) {
try {
val currentPrice = getCurrentMarketPrice(position.marketId, position.outcomeIndex ?: 0)
updateOrdersAsSold(orders, currentPrice)
updateOrdersAsSold(orders, currentPrice, copyTradingId, position.marketId, position.outcomeIndex ?: 0)
} catch (e: Exception) {
logger.error("更新订单状态为已卖出失败: ${e.message}", e)
}
@@ -332,21 +389,85 @@ class PositionCheckService(
/**
* 更新订单状态为已卖出(使用当前最新价)
* 同时创建卖出记录和匹配明细,用于统计
*/
private suspend fun updateOrdersAsSold(
orders: List<CopyOrderTracking>,
sellPrice: BigDecimal
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
outcomeIndex: Int
) {
if (orders.isEmpty()) {
return
}
try {
// 计算总匹配数量和总盈亏
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>()
for (order in orders) {
val remainingQty = order.remainingQuantity.toSafeBigDecimal()
if (remainingQty <= BigDecimal.ZERO) {
continue
}
// 计算盈亏
val buyPrice = order.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(remainingQty)
// 创建匹配明细(稍后保存)
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = remainingQty,
buyPrice = buyPrice,
sellPrice = sellPrice,
realizedPnl = realizedPnl
)
matchDetails.add(detail)
totalMatchedQuantity = totalMatchedQuantity.add(remainingQty)
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
// 更新订单状态:将剩余数量标记为已匹配
order.matchedQuantity = order.matchedQuantity.add(order.remainingQuantity)
order.matchedQuantity = order.matchedQuantity.add(remainingQty)
order.remainingQuantity = BigDecimal.ZERO
order.status = "fully_matched"
order.updatedAt = System.currentTimeMillis()
copyOrderTrackingRepository.save(order)
}
// 如果有匹配的订单,创建卖出记录
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
val timestamp = System.currentTimeMillis()
val sellOrderId = "AUTO_${timestamp}_${copyTradingId}"
val leaderSellTradeId = "AUTO_${timestamp}"
logger.info("更新订单状态为已卖出: orderId=${order.buyOrderId}, marketId=${order.marketId}, sellPrice=$sellPrice")
val matchRecord = SellMatchRecord(
copyTradingId = copyTradingId,
sellOrderId = sellOrderId,
leaderSellTradeId = leaderSellTradeId,
marketId = marketId,
side = outcomeIndex.toString(), // 使用outcomeIndex作为side
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
// 保存匹配明细
for (detail in matchDetails) {
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("创建自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
}
} catch (e: Exception) {
logger.error("更新订单状态为已卖出异常: ${e.message}", e)
@@ -356,15 +477,26 @@ class PositionCheckService(
/**
* 按 FIFO 顺序更新订单状态为已卖出
* 仓位数量小于订单数量总和时,按订单下单顺序更新
* 同时创建卖出记录和匹配明细,用于统计
*/
private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>,
availableQuantity: BigDecimal,
sellPrice: BigDecimal
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
outcomeIndex: Int
) {
if (orders.isEmpty()) {
return
}
try {
// 订单已经按 createdAt ASC 排序(FIFO
var remaining = availableQuantity
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>()
for (order in orders) {
if (remaining <= BigDecimal.ZERO) {
@@ -375,6 +507,25 @@ class PositionCheckService(
val toMatch = minOf(orderRemaining, remaining)
if (toMatch > BigDecimal.ZERO) {
// 计算盈亏
val buyPrice = order.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(toMatch)
// 创建匹配明细(稍后保存)
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = toMatch,
buyPrice = buyPrice,
sellPrice = sellPrice,
realizedPnl = realizedPnl
)
matchDetails.add(detail)
totalMatchedQuantity = totalMatchedQuantity.add(toMatch)
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
order.matchedQuantity = order.matchedQuantity.add(toMatch)
order.remainingQuantity = order.remainingQuantity.subtract(toMatch)
@@ -393,6 +544,35 @@ class PositionCheckService(
logger.info("按 FIFO 更新订单状态: orderId=${order.buyOrderId}, matched=$toMatch, remaining=${order.remainingQuantity}")
}
}
// 如果有匹配的订单,创建卖出记录
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
val timestamp = System.currentTimeMillis()
val sellOrderId = "AUTO_FIFO_${timestamp}_${copyTradingId}"
val leaderSellTradeId = "AUTO_FIFO_${timestamp}"
val matchRecord = SellMatchRecord(
copyTradingId = copyTradingId,
sellOrderId = sellOrderId,
leaderSellTradeId = leaderSellTradeId,
marketId = marketId,
side = outcomeIndex.toString(), // 使用outcomeIndex作为side
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
// 保存匹配明细
for (detail in matchDetails) {
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("创建FIFO自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
}
} catch (e: Exception) {
logger.error("按 FIFO 更新订单状态异常: ${e.message}", e)
}
@@ -0,0 +1,156 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.PositionListResponse
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* 仓位轮训服务
* 独立负责轮训仓位数据,通过事件机制分发给订阅者
* 使用专门的线程处理事件分发,避免阻塞轮训
* 提供丢弃机制:如果消费者处理慢,只保留最新的一份数据
*/
@Service
class PositionPollingService(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
@Value("\${position.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
// 订阅者列表(支持多个订阅者)
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
// 最新仓位数据(用于丢弃机制)
@Volatile
private var latestPositions: PositionListResponse? = null
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 事件分发协程(使用专门的线程,避免阻塞轮训)
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮训)
*/
@PostConstruct
fun init() {
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
startPolling()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
subscribers.clear()
scope.cancel()
eventDispatcherScope.cancel()
}
/**
* 订阅仓位事件
* @param callback 回调函数,接收最新的仓位数据
*/
fun subscribe(callback: (PositionListResponse) -> Unit) {
synchronized(lock) {
subscribers.add(callback)
// 如果有最新数据,立即发送给新订阅者
latestPositions?.let { callback(it) }
}
}
/**
* 取消订阅仓位事件
*/
fun unsubscribe(callback: (PositionListResponse) -> Unit) {
synchronized(lock) {
subscribers.remove(callback)
}
}
/**
* 启动轮训任务
*/
private fun startPolling() {
synchronized(lock) {
// 如果已经有轮训任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮训任务
pollingJob = scope.launch {
while (isActive) {
try {
pollPositions()
} catch (e: Exception) {
logger.error("轮训仓位数据失败: ${e.message}", e)
}
delay(pollingInterval)
}
}
}
}
/**
* 轮训仓位数据并发布事件
* 使用专门的线程分发事件,避免阻塞轮训
* 实现丢弃机制:只保留最新的一份数据
*/
private suspend fun pollPositions() {
try {
val result = accountService.getAllPositions()
if (result.isSuccess) {
val positions = result.getOrNull()
if (positions != null) {
// 更新最新数据(丢弃旧数据,只保留最新的)
latestPositions = positions
// 在专门的线程中分发事件,避免阻塞轮训
eventDispatcherScope.launch {
try {
// 通知所有订阅者(在专门的线程中执行,避免阻塞)
val currentSubscribers = synchronized(lock) {
subscribers.toList() // 复制列表,避免并发修改
}
currentSubscribers.forEach { callback ->
try {
callback(positions)
} catch (e: Exception) {
logger.error("通知订阅者失败: ${e.message}", e)
}
}
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
} catch (e: Exception) {
logger.error("分发仓位数据事件失败: ${e.message}", e)
}
}
}
} else {
logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}")
}
} catch (e: Exception) {
logger.error("轮训仓位数据异常: ${e.message}", e)
}
}
}
@@ -8,25 +8,21 @@ import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 仓位推送服务
* 轮询仓位接口,比较差异并推送增量更新
* 订阅 PositionPollingService 的事件,推送给 WebSocket 客户端
*/
@Service
class PositionPushService(
private val accountService: AccountService,
private val positionCheckService: PositionCheckService
private val positionPollingService: PositionPollingService,
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPushService::class.java)
@Value("\${position.push.polling-interval:3000}")
private var pollingInterval: Long = 3000 // 轮询间隔(毫秒),默认3秒
// 存储客户端会话和对应的推送回调
private val clientCallbacks = ConcurrentHashMap<String, (PositionPushMessage) -> Unit>()
@@ -36,18 +32,18 @@ class PositionPushService(
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
private var subscriptionJob: Job? = null
// 同步锁,确保轮询任务的启动和停止是线程安全的
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮询
* 初始化服务(订阅 PositionPollingService 的事件
*/
@PostConstruct
fun init() {
logger.info("PositionPushService 初始化,启动仓位轮询任务")
startPolling()
logger.info("PositionPushService 初始化,订阅仓位轮训事件")
startSubscription()
}
/**
@@ -56,8 +52,8 @@ class PositionPushService(
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
subscriptionJob?.cancel()
subscriptionJob = null
}
scope.cancel()
}
@@ -78,27 +74,23 @@ class PositionPushService(
/**
* 注册客户端会话(兼容旧接口)
* 轮询任务已在后端启动时启动,这里只需要注册回调
*/
fun registerSession(sessionId: String, callback: (PositionPushMessage) -> Unit) {
logger.info("注册仓位推送客户端会话: $sessionId")
synchronized(lock) {
clientCallbacks[sessionId] = callback
// 轮询任务已在后端启动时启动,不需要在这里启动
}
}
/**
* 注销客户端会话(兼容旧接口)
* 轮询任务持续运行,不因客户端断开而停止
*/
fun unregisterSession(sessionId: String) {
logger.info("注销仓位推送客户端会话: $sessionId")
synchronized(lock) {
clientCallbacks.remove(sessionId)
// 轮询任务持续运行,不停止
}
}
@@ -134,179 +126,59 @@ class PositionPushService(
}
/**
* 启动轮询任务
* 启动订阅任务(订阅 PositionPollingService 的事件)
*/
private fun startPolling() {
private fun startSubscription() {
synchronized(lock) {
// 如果已经有轮询任务在运行,先取消
pollingJob?.cancel()
// 如果已经有订阅任务在运行,先取消
subscriptionJob?.cancel()
// 启动新的轮询任务
pollingJob = scope.launch {
while (isActive) {
try {
pollAndPush()
} catch (e: Exception) {
logger.error("轮询仓位数据失败: ${e.message}", e)
}
delay(pollingInterval)
}
}
}
}
/**
* 停止轮询任务
*/
private fun stopPolling() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
}
/**
* 轮询仓位数据并推送全量数据
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
*/
private suspend fun pollAndPush() {
try {
val result = accountService.getAllPositions()
if (result.isSuccess) {
val positions = result.getOrNull()
if (positions != null) {
// 更新快照
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
// 向所有订阅者发送全量数据
if (clientCallbacks.isNotEmpty()) {
val message = PositionPushMessage(
type = PositionPushMessageType.FULL,
timestamp = System.currentTimeMillis(),
currentPositions = positions.currentPositions,
historyPositions = positions.historyPositions
)
// 推送给所有连接的客户端
clientCallbacks.values.forEach { callback ->
try {
callback(message)
} catch (e: Exception) {
logger.error("推送全量数据失败: ${e.message}", e)
}
// 启动新的订阅任务(使用专门的线程,避免阻塞)
subscriptionJob = scope.launch(Dispatchers.IO) {
try {
// 订阅仓位轮训事件
positionPollingService.subscribe { positions ->
try {
handlePositionUpdate(positions)
} catch (e: Exception) {
logger.error("处理仓位更新事件失败: ${e.message}", e)
}
}
// 仓位检查逻辑(复用仓位轮询)
// 处理待赎回仓位和未卖出订单
positionCheckService.checkPositions(positions.currentPositions)
} catch (e: Exception) {
logger.error("订阅仓位轮训事件失败: ${e.message}", e)
}
} else {
logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}")
}
} catch (e: Exception) {
logger.error("轮询仓位数据异常: ${e.message}", e)
}
}
/**
* 计算增量更新
* 返回 null 表示没有变化
* 处理仓位更新事件
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
*/
private fun calculateIncremental(
newCurrentPositions: List<AccountPositionDto>,
newHistoryPositions: List<AccountPositionDto>
): IncrementalUpdate? {
val newCurrentMap = newCurrentPositions.associateBy { it.getPositionKey() }
val newHistoryMap = newHistoryPositions.associateBy { it.getPositionKey() }
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
// 更新快照
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
// 找出新增或更新的当前仓位
val updatedCurrentPositions = mutableListOf<AccountPositionDto>()
newCurrentMap.forEach { (key, newPos) ->
val oldPos = lastCurrentPositions[key]
if (oldPos == null || hasChanged(oldPos, newPos)) {
updatedCurrentPositions.add(newPos)
}
}
// 找出新增或更新的历史仓位
val updatedHistoryPositions = mutableListOf<AccountPositionDto>()
newHistoryMap.forEach { (key, newPos) ->
val oldPos = lastHistoryPositions[key]
if (oldPos == null || hasChanged(oldPos, newPos)) {
updatedHistoryPositions.add(newPos)
}
}
// 找出已删除的仓位(从当前仓位变为历史仓位,或完全删除)
val removedKeys = mutableListOf<String>()
// 检查上次的当前仓位是否还在当前仓位列表中
lastCurrentPositions.forEach { (key, _) ->
if (!newCurrentMap.containsKey(key)) {
// 如果不在当前仓位中,检查是否移到了历史仓位
if (!newHistoryMap.containsKey(key)) {
// 完全删除
removedKeys.add(key)
} else {
// 从当前仓位移到历史仓位,需要更新历史仓位
newHistoryMap[key]?.let { updatedHistoryPositions.add(it) }
// 向所有订阅者发送全量数据
if (clientCallbacks.isNotEmpty()) {
val message = PositionPushMessage(
type = PositionPushMessageType.FULL,
timestamp = System.currentTimeMillis(),
currentPositions = positions.currentPositions,
historyPositions = positions.historyPositions
)
// 推送给所有连接的客户端(在专门的线程中执行,避免阻塞)
scope.launch(Dispatchers.IO) {
clientCallbacks.values.forEach { callback ->
try {
callback(message)
} catch (e: Exception) {
logger.error("推送全量数据失败: ${e.message}", e)
}
}
}
}
// 检查上次的历史仓位是否还在历史仓位列表中
lastHistoryPositions.forEach { (key, _) ->
if (!newHistoryMap.containsKey(key)) {
// 如果不在历史仓位中,检查是否移到了当前仓位
if (!newCurrentMap.containsKey(key)) {
// 完全删除
removedKeys.add(key)
} else {
// 从历史仓位移到当前仓位,需要更新当前仓位
newCurrentMap[key]?.let { updatedCurrentPositions.add(it) }
}
}
}
// 如果没有变化,返回 null
if (updatedCurrentPositions.isEmpty() && updatedHistoryPositions.isEmpty() && removedKeys.isEmpty()) {
return null
}
return IncrementalUpdate(
currentPositions = updatedCurrentPositions,
historyPositions = updatedHistoryPositions,
removedKeys = removedKeys
)
}
/**
* 检查仓位是否有变化
* 比较关键字段:数量、价格、价值、盈亏等
*/
private fun hasChanged(old: AccountPositionDto, new: AccountPositionDto): Boolean {
return old.quantity != new.quantity ||
old.avgPrice != new.avgPrice ||
old.currentPrice != new.currentPrice ||
old.currentValue != new.currentValue ||
old.pnl != new.pnl ||
old.percentPnl != new.percentPnl ||
old.realizedPnl != new.realizedPnl ||
old.percentRealizedPnl != new.percentRealizedPnl ||
old.redeemable != new.redeemable ||
old.mergeable != new.mergeable ||
old.isCurrent != new.isCurrent
}
/**
* 增量更新数据
*/
private data class IncrementalUpdate(
val currentPositions: List<AccountPositionDto>,
val historyPositions: List<AccountPositionDto>,
val removedKeys: List<String>
)
}
@@ -17,16 +17,16 @@ class SystemConfigService(
private val systemConfigRepository: SystemConfigRepository,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(SystemConfigService::class.java)
companion object {
const val CONFIG_KEY_BUILDER_API_KEY = "builder.api_key"
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
}
/**
* 获取系统配置
*/
@@ -35,15 +35,15 @@ class SystemConfigService(
val builderSecret = getConfigValue(CONFIG_KEY_BUILDER_SECRET)
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
return SystemConfigDto(
builderApiKeyConfigured = builderApiKey != null,
builderSecretConfigured = builderSecret != null,
builderPassphraseConfigured = builderPassphrase != null,
autoRedeem = autoRedeem
autoRedeemEnabled = autoRedeem
)
}
/**
* 更新 Builder API Key 配置
*/
@@ -61,7 +61,7 @@ class SystemConfigService(
}
)
}
// 更新 Builder Secret
if (request.builderSecret != null) {
updateConfigValue(
@@ -73,7 +73,7 @@ class SystemConfigService(
}
)
}
// 更新 Builder Passphrase
if (request.builderPassphrase != null) {
updateConfigValue(
@@ -85,7 +85,7 @@ class SystemConfigService(
}
)
}
// 更新自动赎回配置
if (request.autoRedeem != null) {
updateConfigValue(
@@ -93,29 +93,29 @@ class SystemConfigService(
request.autoRedeem.toString()
)
}
Result.success(getSystemConfig())
} catch (e: Exception) {
logger.error("更新系统配置失败", e)
Result.failure(e)
}
}
/**
* 获取配置值(解密)
*/
fun getBuilderApiKey(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_API_KEY)?.let { cryptoUtils.decrypt(it) }
}
fun getBuilderSecret(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_SECRET)?.let { cryptoUtils.decrypt(it) }
}
fun getBuilderPassphrase(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)?.let { cryptoUtils.decrypt(it) }
}
/**
* 检查 Builder API Key 是否已配置
*/
@@ -125,7 +125,7 @@ class SystemConfigService(
val passphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
return apiKey != null && secret != null && passphrase != null
}
/**
* 检查自动赎回是否启用
*/
@@ -134,10 +134,10 @@ class SystemConfigService(
return when (autoRedeemValue?.lowercase()) {
"true" -> true
"false" -> false
else -> true // 默认开启
else -> false // 默认开启
}
}
/**
* 更新自动赎回配置
*/
@@ -151,14 +151,14 @@ class SystemConfigService(
Result.failure(e)
}
}
/**
* 获取配置值(原始值,加密存储)
*/
private fun getConfigValue(configKey: String): String? {
return systemConfigRepository.findByConfigKey(configKey)?.configValue
}
/**
* 更新配置值
*/
@@ -0,0 +1,8 @@
-- ============================================
-- V10: 添加 Leader 备注和网站字段
-- ============================================
ALTER TABLE copy_trading_leaders
ADD COLUMN remark TEXT NULL COMMENT 'Leader 备注(可选)',
ADD COLUMN website VARCHAR(500) NULL COMMENT 'Leader 网站(可选)';
-8
View File
@@ -31,11 +31,7 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
import FilteredOrdersList from './pages/FilteredOrdersList'
import SystemSettings from './pages/SystemSettings'
import LanguageSettings from './pages/LanguageSettings'
import ApiHealthStatus from './pages/ApiHealthStatus'
import ProxySettings from './pages/ProxySettings'
import NotificationSettings from './pages/NotificationSettings'
import BuilderApiKeySettings from './pages/BuilderApiKeySettings'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -264,11 +260,7 @@ function App() {
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
<Route path="/system-settings/language" element={<ProtectedRoute><LanguageSettings /></ProtectedRoute>} />
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
<Route path="/system-settings/proxy" element={<ProtectedRoute><ProxySettings /></ProtectedRoute>} />
<Route path="/system-settings/notifications" element={<ProtectedRoute><NotificationSettings /></ProtectedRoute>} />
<Route path="/system-settings/builder-api-key" element={<ProtectedRoute><BuilderApiKeySettings /></ProtectedRoute>} />
{/* 默认重定向到登录页 */}
<Route path="*" element={<Navigate to="/login" replace />} />
+4 -27
View File
@@ -17,10 +17,7 @@ import {
SettingOutlined,
GithubOutlined,
TwitterOutlined,
GlobalOutlined,
CheckCircleOutlined,
NotificationOutlined,
KeyOutlined
CheckCircleOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -120,37 +117,17 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
{
key: '/system-settings',
icon: <SettingOutlined />,
label: t('menu.systemSettings'),
label: t('menu.systemSettings') || '系统管理',
children: [
{
key: '/system-settings',
icon: <SettingOutlined />,
label: t('menu.systemOverview') || '概览'
},
{
key: '/system-settings/language',
icon: <GlobalOutlined />,
label: t('menu.language')
label: t('menu.systemOverview') || '通用设置'
},
{
key: '/system-settings/api-health',
icon: <CheckCircleOutlined />,
label: t('menu.apiHealth')
},
{
key: '/system-settings/proxy',
icon: <LinkOutlined />,
label: t('menu.proxy')
},
{
key: '/system-settings/builder-api-key',
icon: <KeyOutlined />,
label: t('menu.builderApiKey') || 'Builder API Key'
},
{
key: '/system-settings/notifications',
icon: <NotificationOutlined />,
label: t('menu.notifications')
label: t('menu.apiHealth') || 'API健康'
}
]
},
+68 -4
View File
@@ -305,15 +305,30 @@
"footer": "Please use the above pages for configuration management."
},
"systemSettings": {
"title": "General Settings",
"language": {
"title": "Language Settings",
"currentLanguage": "Current Language",
"languageRequired": "Please select a language"
},
"notification": {
"title": "Notification Settings"
},
"relayer": {
"title": "Relayer Configuration"
},
"proxy": {
"title": "Proxy Settings"
},
"autoRedeem": {
"title": "Auto Redeem Configuration",
"label": "Auto Redeem",
"tooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
"label": "Enable Auto Redeem",
"tooltip": "When enabled, the system will automatically redeem all redeemable positions in all accounts. Requires Builder API Key configuration to take effect.",
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
"builderApiKeyNotConfiguredDesc": "Auto redeem feature requires Builder API Key configuration to take effect.",
"goToConfigure": "Go to Configure",
"saveSuccess": "Auto redeem configuration updated",
"saveFailed": "Failed to update auto redeem configuration"
"saveSuccess": "Auto redeem configuration saved",
"saveFailed": "Failed to save auto redeem configuration"
}
},
"builderApiKey": {
@@ -394,6 +409,9 @@
"leaderName": "Leader Name",
"walletAddress": "Wallet Address",
"category": "Category",
"remark": "Remark",
"website": "Website",
"openWebsite": "Open Website",
"all": "All",
"copyTradingCount": "Copy Trading Count",
"copyTradingRelations": "{{count}} copy trading relations",
@@ -405,6 +423,52 @@
"deleteConfirm": "Are you sure you want to delete this Leader?",
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first"
},
"leaderAdd": {
"title": "Add Leader",
"leaderAddress": "Leader Wallet Address",
"leaderAddressRequired": "Please enter Leader wallet address",
"leaderAddressInvalid": "Invalid wallet address format (must be a 42-character address starting with 0x)",
"leaderAddressTooltip": "The wallet address of the leader to be copied. The system will monitor transactions from this address and automatically copy trade",
"leaderName": "Leader Name",
"leaderNameTooltip": "Optional, used to identify the Leader for easier management",
"leaderNamePlaceholder": "Optional, used to identify the Leader",
"remark": "Leader Remark",
"remarkTooltip": "Optional, used to record remarks about the Leader",
"remarkPlaceholder": "Optional, used to record remarks about the Leader",
"website": "Leader Website",
"websiteTooltip": "Optional, Leader's website link",
"websitePlaceholder": "Optional, e.g.: https://example.com",
"websiteInvalid": "Please enter a valid URL address",
"category": "Category Filter",
"categoryTooltip": "Only copy trades of this category. Leave empty to copy all categories (sports or crypto)",
"categoryPlaceholder": "Select category (optional)",
"add": "Add Leader",
"cancel": "Cancel",
"addSuccess": "Leader added successfully",
"addFailed": "Failed to add Leader"
},
"leaderEdit": {
"title": "Edit Leader",
"leaderName": "Leader Name",
"leaderNameTooltip": "Optional, used to identify the Leader for easier management",
"leaderNamePlaceholder": "Optional, used to identify the Leader",
"remark": "Leader Remark",
"remarkTooltip": "Optional, used to record remarks about the Leader",
"remarkPlaceholder": "Optional, used to record remarks about the Leader",
"website": "Leader Website",
"websiteTooltip": "Optional, Leader's website link",
"websitePlaceholder": "Optional, e.g.: https://example.com",
"websiteInvalid": "Please enter a valid URL address",
"category": "Category Filter",
"categoryTooltip": "Only copy trades of this category. Leave empty to copy all categories (sports or crypto)",
"categoryPlaceholder": "Select category (optional)",
"save": "Save",
"cancel": "Cancel",
"saveSuccess": "Leader updated successfully",
"saveFailed": "Failed to update Leader",
"fetchFailed": "Failed to get Leader details",
"invalidId": "Invalid Leader ID"
},
"userList": {
"title": "User Management",
"username": "Username",
+72 -5
View File
@@ -169,7 +169,10 @@
"lastCheck": "最后检查",
"healthy": "健康",
"unhealthy": "不健康",
"unknown": "未知"
"unknown": "未知",
"normal": "正常",
"notConfigured": "未配置",
"abnormal": "异常"
},
"proxySettings": {
"title": "代理设置",
@@ -217,15 +220,30 @@
"footer": "请使用上述页面进行配置管理。"
},
"systemSettings": {
"title": "通用设置",
"language": {
"title": "多语言设置",
"currentLanguage": "当前语言",
"languageRequired": "请选择语言"
},
"notification": {
"title": "消息推送设置"
},
"relayer": {
"title": "Relayer 配置"
},
"proxy": {
"title": "代理设置"
},
"autoRedeem": {
"title": "自动赎回配置",
"label": "自动赎回",
"tooltip": "开启后,系统自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
"label": "启用自动赎回",
"tooltip": "开启后,系统自动赎回所有账户中可赎回的仓位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置",
"builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。",
"goToConfigure": "前往配置",
"saveSuccess": "自动赎回配置已更新",
"saveFailed": "更新自动赎回配置失败"
"saveSuccess": "自动赎回配置已保存",
"saveFailed": "保存自动赎回配置失败"
}
},
"builderApiKey": {
@@ -306,6 +324,9 @@
"leaderName": "Leader 名称",
"walletAddress": "钱包地址",
"category": "分类",
"remark": "备注",
"website": "网站",
"openWebsite": "打开网页",
"all": "全部",
"copyTradingCount": "跟单关系数",
"copyTradingRelations": "{{count}} 个跟单关系",
@@ -317,6 +338,52 @@
"deleteConfirm": "确定要删除这个 Leader 吗?",
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系"
},
"leaderAdd": {
"title": "添加 Leader",
"leaderAddress": "Leader 钱包地址",
"leaderAddressRequired": "请输入 Leader 钱包地址",
"leaderAddressInvalid": "钱包地址格式不正确(必须是 0x 开头的 42 位地址)",
"leaderAddressTooltip": "被跟单者的钱包地址,系统将监控该地址的交易并自动跟单",
"leaderName": "Leader 名称",
"leaderNameTooltip": "可选,用于标识 Leader,方便管理",
"leaderNamePlaceholder": "可选,用于标识 Leader",
"remark": "Leader 备注",
"remarkTooltip": "可选,用于记录 Leader 的备注信息",
"remarkPlaceholder": "可选,用于记录 Leader 的备注信息",
"website": "Leader 网站",
"websiteTooltip": "可选,Leader 的网站链接",
"websitePlaceholder": "可选,例如:https://example.com",
"websiteInvalid": "请输入有效的 URL 地址",
"category": "分类筛选",
"categoryTooltip": "仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto",
"categoryPlaceholder": "选择分类(可选)",
"add": "添加 Leader",
"cancel": "取消",
"addSuccess": "添加 Leader 成功",
"addFailed": "添加 Leader 失败"
},
"leaderEdit": {
"title": "编辑 Leader",
"leaderName": "Leader 名称",
"leaderNameTooltip": "可选,用于标识 Leader,方便管理",
"leaderNamePlaceholder": "可选,用于标识 Leader",
"remark": "Leader 备注",
"remarkTooltip": "可选,用于记录 Leader 的备注信息",
"remarkPlaceholder": "可选,用于记录 Leader 的备注信息",
"website": "Leader 网站",
"websiteTooltip": "可选,Leader 的网站链接",
"websitePlaceholder": "可选,例如:https://example.com",
"websiteInvalid": "请输入有效的 URL 地址",
"category": "分类筛选",
"categoryTooltip": "仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto",
"categoryPlaceholder": "选择分类(可选)",
"save": "保存",
"cancel": "取消",
"saveSuccess": "更新 Leader 成功",
"saveFailed": "更新 Leader 失败",
"fetchFailed": "获取 Leader 详情失败",
"invalidId": "Leader ID 无效"
},
"userList": {
"title": "用户管理",
"username": "用户名",
+68 -4
View File
@@ -305,15 +305,30 @@
"footer": "請使用上述頁面進行配置管理。"
},
"systemSettings": {
"title": "通用設置",
"language": {
"title": "多語言設置",
"currentLanguage": "當前語言",
"languageRequired": "請選擇語言"
},
"notification": {
"title": "消息推送設置"
},
"relayer": {
"title": "Relayer 配置"
},
"proxy": {
"title": "代理設置"
},
"autoRedeem": {
"title": "自動贖回配置",
"label": "自動贖回",
"tooltip": "開啟後,系統自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
"label": "啟用自動贖回",
"tooltip": "開啟後,系統自動贖回所有賬戶中可贖回的倉位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置",
"builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。",
"goToConfigure": "前往配置",
"saveSuccess": "自動贖回配置已更新",
"saveFailed": "更新自動贖回配置失敗"
"saveSuccess": "自動贖回配置已保存",
"saveFailed": "保存自動贖回配置失敗"
}
},
"builderApiKey": {
@@ -394,6 +409,9 @@
"leaderName": "Leader 名稱",
"walletAddress": "錢包地址",
"category": "分類",
"remark": "備註",
"website": "網站",
"openWebsite": "打開網頁",
"all": "全部",
"copyTradingCount": "跟單關係數",
"copyTradingRelations": "{{count}} 個跟單關係",
@@ -405,6 +423,52 @@
"deleteConfirm": "確定要刪除這個 Leader 嗎?",
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係"
},
"leaderAdd": {
"title": "添加 Leader",
"leaderAddress": "Leader 錢包地址",
"leaderAddressRequired": "請輸入 Leader 錢包地址",
"leaderAddressInvalid": "錢包地址格式不正確(必須是 0x 開頭的 42 位地址)",
"leaderAddressTooltip": "被跟單者的錢包地址,系統將監控該地址的交易並自動跟單",
"leaderName": "Leader 名稱",
"leaderNameTooltip": "可選,用於標識 Leader,方便管理",
"leaderNamePlaceholder": "可選,用於標識 Leader",
"remark": "Leader 備註",
"remarkTooltip": "可選,用於記錄 Leader 的備註信息",
"remarkPlaceholder": "可選,用於記錄 Leader 的備註信息",
"website": "Leader 網站",
"websiteTooltip": "可選,Leader 的網站鏈接",
"websitePlaceholder": "可選,例如:https://example.com",
"websiteInvalid": "請輸入有效的 URL 地址",
"category": "分類篩選",
"categoryTooltip": "僅跟單該分類的交易,不選擇則跟單所有分類(sports 或 crypto",
"categoryPlaceholder": "選擇分類(可選)",
"add": "添加 Leader",
"cancel": "取消",
"addSuccess": "添加 Leader 成功",
"addFailed": "添加 Leader 失敗"
},
"leaderEdit": {
"title": "編輯 Leader",
"leaderName": "Leader 名稱",
"leaderNameTooltip": "可選,用於標識 Leader,方便管理",
"leaderNamePlaceholder": "可選,用於標識 Leader",
"remark": "Leader 備註",
"remarkTooltip": "可選,用於記錄 Leader 的備註信息",
"remarkPlaceholder": "可選,用於記錄 Leader 的備註信息",
"website": "Leader 網站",
"websiteTooltip": "可選,Leader 的網站鏈接",
"websitePlaceholder": "可選,例如:https://example.com",
"websiteInvalid": "請輸入有效的 URL 地址",
"category": "分類篩選",
"categoryTooltip": "僅跟單該分類的交易,不選擇則跟單所有分類(sports 或 crypto",
"categoryPlaceholder": "選擇分類(可選)",
"save": "保存",
"cancel": "取消",
"saveSuccess": "更新 Leader 成功",
"saveFailed": "更新 Leader 失敗",
"fetchFailed": "獲取 Leader 詳情失敗",
"invalidId": "Leader ID 無效"
},
"userList": {
"title": "用戶管理",
"username": "用戶名",
+49 -18
View File
@@ -4,12 +4,14 @@ import { Card, Form, Input, Button, Select, message, Typography, Space } from 'a
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import { isValidWalletAddress } from '../utils'
const { Title } = Typography
const { Option } = Select
const LeaderAdd: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [form] = Form.useForm()
@@ -21,17 +23,19 @@ const LeaderAdd: React.FC = () => {
const response = await apiService.leaders.add({
leaderAddress: values.leaderAddress.trim(),
leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined,
category: values.category || undefined
})
if (response.data.code === 0) {
message.success('添加 Leader 成功')
message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功')
navigate('/leaders')
} else {
message.error(response.data.msg || '添加 Leader 失败')
message.error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败')
}
} catch (error: any) {
message.error(error.message || '添加 Leader 失败')
message.error(error.message || t('leaderAdd.addFailed') || '添加 Leader 失败')
} finally {
setLoading(false)
}
@@ -47,7 +51,7 @@ const LeaderAdd: React.FC = () => {
>
</Button>
<Title level={2} style={{ margin: 0 }}> Leader</Title>
<Title level={2} style={{ margin: 0 }}>{t('leaderAdd.title') || '添加 Leader'}</Title>
</div>
<Card>
@@ -61,41 +65,68 @@ const LeaderAdd: React.FC = () => {
}}
>
<Form.Item
label="Leader 钱包地址"
label={t('leaderAdd.leaderAddress') || 'Leader 钱包地址'}
name="leaderAddress"
rules={[
{ required: true, message: '请输入 Leader 钱包地址' },
{ required: true, message: t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址' },
{
validator: (_, value) => {
if (!value) {
return Promise.reject(new Error('请输入 Leader 钱包地址'))
return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址'))
}
if (!isValidWalletAddress(value.trim())) {
return Promise.reject(new Error('钱包地址格式不正确(必须是 0x 开头的 42 位地址)'))
return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)'))
}
return Promise.resolve()
}
}
]}
tooltip="被跟单者的钱包地址,系统将监控该地址的交易并自动跟单"
tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'}
>
<Input placeholder="0x..." style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item
label="Leader 名称"
label={t('leaderAdd.leaderName') || 'Leader 名称'}
name="leaderName"
tooltip="可选,用于标识 Leader,方便管理"
tooltip={t('leaderAdd.leaderNameTooltip') || '可选,用于标识 Leader,方便管理'}
>
<Input placeholder="可选,用于标识 Leader" />
<Input placeholder={t('leaderAdd.leaderNamePlaceholder') || '可选,用于标识 Leader'} />
</Form.Item>
<Form.Item
label="分类筛选"
name="category"
tooltip="仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto"
label={t('leaderAdd.remark') || 'Leader 备注'}
name="remark"
tooltip={t('leaderAdd.remarkTooltip') || '可选,用于记录 Leader 的备注信息'}
>
<Select placeholder="选择分类(可选)" allowClear>
<Input.TextArea
placeholder={t('leaderAdd.remarkPlaceholder') || '可选,用于记录 Leader 的备注信息'}
rows={3}
maxLength={500}
showCount
/>
</Form.Item>
<Form.Item
label={t('leaderAdd.website') || 'Leader 网站'}
name="website"
tooltip={t('leaderAdd.websiteTooltip') || '可选,Leader 的网站链接'}
rules={[
{
type: 'url',
message: t('leaderAdd.websiteInvalid') || '请输入有效的 URL 地址'
}
]}
>
<Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item>
<Form.Item
label={t('leaderAdd.category') || '分类筛选'}
name="category"
tooltip={t('leaderAdd.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto'}
>
<Select placeholder={t('leaderAdd.categoryPlaceholder') || '选择分类(可选)'} allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
@@ -109,10 +140,10 @@ const LeaderAdd: React.FC = () => {
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
Leader
{t('leaderAdd.add') || '添加 Leader'}
</Button>
<Button onClick={() => navigate('/leaders')}>
{t('leaderAdd.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
+50 -17
View File
@@ -4,12 +4,14 @@ import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } f
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { Leader } from '../types'
const { Title } = Typography
const { Option } = Select
const LeaderEdit: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -22,7 +24,7 @@ const LeaderEdit: React.FC = () => {
if (leaderId) {
fetchLeaderDetail(parseInt(leaderId))
} else {
message.error('Leader ID 无效')
message.error(t('leaderEdit.invalidId') || 'Leader ID 无效')
navigate('/leaders')
}
}, [leaderId, navigate])
@@ -35,14 +37,16 @@ const LeaderEdit: React.FC = () => {
const leader: Leader = response.data.data
form.setFieldsValue({
leaderName: leader.leaderName || '',
remark: leader.remark || '',
website: leader.website || '',
category: leader.category || undefined
})
} else {
message.error(response.data.msg || '获取 Leader 详情失败')
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
navigate('/leaders')
}
} catch (error: any) {
message.error(error.message || '获取 Leader 详情失败')
message.error(error.message || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
navigate('/leaders')
} finally {
setFetching(false)
@@ -51,7 +55,7 @@ const LeaderEdit: React.FC = () => {
const handleSubmit = async (values: any) => {
if (!leaderId) {
message.error('Leader ID 无效')
message.error(t('leaderEdit.invalidId') || 'Leader ID 无效')
return
}
@@ -60,17 +64,19 @@ const LeaderEdit: React.FC = () => {
const response = await apiService.leaders.update({
leaderId: parseInt(leaderId),
leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined,
category: values.category || undefined
})
if (response.data.code === 0) {
message.success('更新 Leader 成功')
message.success(t('leaderEdit.saveSuccess') || '更新 Leader 成功')
navigate('/leaders')
} else {
message.error(response.data.msg || '更新 Leader 失败')
message.error(response.data.msg || t('leaderEdit.saveFailed') || '更新 Leader 失败')
}
} catch (error: any) {
message.error(error.message || '更新 Leader 失败')
message.error(error.message || t('leaderEdit.saveFailed') || '更新 Leader 失败')
} finally {
setLoading(false)
}
@@ -94,7 +100,7 @@ const LeaderEdit: React.FC = () => {
>
</Button>
<Title level={2} style={{ margin: 0 }}> Leader</Title>
<Title level={2} style={{ margin: 0 }}>{t('leaderEdit.title') || '编辑 Leader'}</Title>
</div>
<Card>
@@ -105,19 +111,46 @@ const LeaderEdit: React.FC = () => {
size={isMobile ? 'middle' : 'large'}
>
<Form.Item
label="Leader 名称"
label={t('leaderEdit.leaderName') || 'Leader 名称'}
name="leaderName"
tooltip="可选,用于标识 Leader,方便管理"
tooltip={t('leaderEdit.leaderNameTooltip') || '可选,用于标识 Leader,方便管理'}
>
<Input placeholder="可选,用于标识 Leader" />
<Input placeholder={t('leaderEdit.leaderNamePlaceholder') || '可选,用于标识 Leader'} />
</Form.Item>
<Form.Item
label="分类筛选"
name="category"
tooltip="仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto"
label={t('leaderEdit.remark') || 'Leader 备注'}
name="remark"
tooltip={t('leaderEdit.remarkTooltip') || '可选,用于记录 Leader 的备注信息'}
>
<Select placeholder="选择分类(可选)" allowClear>
<Input.TextArea
placeholder={t('leaderEdit.remarkPlaceholder') || '可选,用于记录 Leader 的备注信息'}
rows={3}
maxLength={500}
showCount
/>
</Form.Item>
<Form.Item
label={t('leaderEdit.website') || 'Leader 网站'}
name="website"
tooltip={t('leaderEdit.websiteTooltip') || '可选,Leader 的网站链接'}
rules={[
{
type: 'url',
message: t('leaderEdit.websiteInvalid') || '请输入有效的 URL 地址'
}
]}
>
<Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item>
<Form.Item
label={t('leaderEdit.category') || '分类筛选'}
name="category"
tooltip={t('leaderEdit.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto'}
>
<Select placeholder={t('leaderEdit.categoryPlaceholder') || '选择分类(可选)'} allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
@@ -131,10 +164,10 @@ const LeaderEdit: React.FC = () => {
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
{t('leaderEdit.save') || '保存'}
</Button>
<Button onClick={() => navigate('/leaders')}>
{t('leaderEdit.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
+69 -7
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, GlobalOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { Leader } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Text } = Typography
const LeaderList: React.FC = () => {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
@@ -73,6 +75,16 @@ const LeaderList: React.FC = () => {
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
) : <Tag>{t('leaderList.all') || '全部'}</Tag>
},
{
title: t('leaderList.remark') || '备注',
dataIndex: 'remark',
key: 'remark',
render: (remark: string | undefined) => remark ? (
<Text ellipsis={{ tooltip: remark }} style={{ maxWidth: isMobile ? 100 : 200 }}>
{remark}
</Text>
) : <Text type="secondary">-</Text>
},
{
title: t('leaderList.copyTradingCount') || '跟单关系数',
dataIndex: 'copyTradingCount',
@@ -97,9 +109,19 @@ const LeaderList: React.FC = () => {
{
title: t('common.actions') || '操作',
key: 'action',
width: isMobile ? 120 : 150,
width: isMobile ? 150 : 200,
render: (_: any, record: Leader) => (
<Space size="small">
<Space size="small" wrap>
{record.website && (
<Button
type="link"
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
>
{t('leaderList.openWebsite') || '打开网页'}
</Button>
)}
<Button
type="link"
size="small"
@@ -199,6 +221,35 @@ const LeaderList: React.FC = () => {
</div>
</div>
{/* 备注 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderList.remark') || '备注'}
</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>
{leader.remark}
</Text>
</div>
)}
{/* 网站 */}
{leader.website && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderList.website') || '网站'}
</Text>
<a
href={leader.website}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', marginLeft: '4px' }}
>
<LinkOutlined /> {leader.website}
</a>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* 分类和跟单关系数 */}
@@ -221,13 +272,24 @@ const LeaderList: React.FC = () => {
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{leader.website && (
<Button
type="link"
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
style={{ flex: 1, minWidth: '80px' }}
>
{t('leaderList.openWebsite') || '打开网页'}
</Button>
)}
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ flex: 1 }}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
</Button>
@@ -243,7 +305,7 @@ const LeaderList: React.FC = () => {
size="small"
danger
icon={<DeleteOutlined />}
style={{ flex: 1 }}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.delete') || '删除'}
</Button>
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -285,13 +285,13 @@ export const apiService = {
/**
* 添加 Leader
*/
add: (data: { leaderAddress: string; leaderName?: string; category?: string }) =>
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
/**
* 更新 Leader
*/
update: (data: { leaderId: number; leaderName?: string; category?: string }) =>
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
/**
+3 -1
View File
@@ -60,6 +60,8 @@ export interface Leader {
leaderAddress: string
leaderName?: string
category?: string
remark?: string // Leader 备注(可选)
website?: string // Leader 网站(可选)
copyTradingCount: number
totalOrders?: number
totalPnl?: string
@@ -772,7 +774,7 @@ export interface SystemConfig {
builderApiKeyConfigured: boolean
builderSecretConfigured: boolean
builderPassphraseConfigured: boolean
autoRedeem: boolean // 自动赎回(系统级别配置,默认开启)
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
}
/**