fix: 修复未匹配订单更新逻辑和前端API类型定义
- 修复 PositionCheckService 中未匹配订单更新逻辑,当仓位数量大于等于订单数量总和时也能正确处理 - 修复前端 API 类型定义,添加 remark 和 website 字段支持 - 添加 Leader 备注和网站字段支持
This commit is contained in:
@@ -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 或 crypto,null 表示不筛选
|
||||
|
||||
@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 网站(可选)';
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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健康'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "用户名",
|
||||
|
||||
@@ -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": "用戶名",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 // 自动赎回(系统级别配置,默认开启)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user