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 {
|
} else {
|
||||||
ResponseEntity.ok(
|
ResponseEntity.ok(
|
||||||
ApiResponse.error(
|
ApiResponse.error(
|
||||||
ErrorCode.BUSINESS_ERROR,
|
ErrorCode.BUSINESS_ERROR,
|
||||||
e.message,
|
e.message,
|
||||||
messageSource
|
messageSource
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ data class SystemConfigDto(
|
|||||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
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(
|
data class LeaderAddRequest(
|
||||||
val leaderAddress: String,
|
val leaderAddress: String,
|
||||||
val leaderName: String? = null,
|
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(
|
data class LeaderUpdateRequest(
|
||||||
val leaderId: Long,
|
val leaderId: Long,
|
||||||
val leaderName: String? = null,
|
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 leaderAddress: String,
|
||||||
val leaderName: String?,
|
val leaderName: String?,
|
||||||
val category: String?,
|
val category: String?,
|
||||||
|
val remark: String? = null, // Leader 备注(可选)
|
||||||
|
val website: String? = null, // Leader 网站(可选)
|
||||||
val copyTradingCount: Long = 0, // 跟单关系数量
|
val copyTradingCount: Long = 0, // 跟单关系数量
|
||||||
val totalOrders: Long? = null, // 总订单数(可选)
|
val totalOrders: Long? = null, // 总订单数(可选)
|
||||||
val totalPnl: String? = null, // 总盈亏(可选)
|
val totalPnl: String? = null, // 总盈亏(可选)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ data class Leader(
|
|||||||
@Column(name = "category", length = 20)
|
@Column(name = "category", length = 20)
|
||||||
val category: String? = null, // sports 或 crypto,null 表示不筛选
|
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)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,19 @@ class LeaderService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 创建 Leader
|
// 5. 创建 Leader
|
||||||
|
// 如果 website 为空,自动设置为 polymarket profile 页
|
||||||
|
val website = if (request.website.isNullOrBlank()) {
|
||||||
|
"https://polymarket.com/profile/${request.leaderAddress}"
|
||||||
|
} else {
|
||||||
|
request.website
|
||||||
|
}
|
||||||
|
|
||||||
val leader = Leader(
|
val leader = Leader(
|
||||||
leaderAddress = request.leaderAddress,
|
leaderAddress = request.leaderAddress,
|
||||||
leaderName = request.leaderName,
|
leaderName = request.leaderName?.takeIf { it.isNotBlank() },
|
||||||
category = request.category
|
category = request.category,
|
||||||
|
remark = request.remark?.takeIf { it.isNotBlank() },
|
||||||
|
website = website
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = leaderRepository.save(leader)
|
val saved = leaderRepository.save(leader)
|
||||||
@@ -78,9 +87,19 @@ class LeaderService(
|
|||||||
CategoryValidator.validate(request.category)
|
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(
|
val updated = leader.copy(
|
||||||
leaderName = request.leaderName ?: leader.leaderName,
|
leaderName = request.leaderName?.takeIf { it.isNotBlank() },
|
||||||
category = request.category ?: leader.category,
|
category = request.category,
|
||||||
|
remark = request.remark?.takeIf { it.isNotBlank() },
|
||||||
|
website = website,
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,6 +194,8 @@ class LeaderService(
|
|||||||
leaderAddress = leader.leaderAddress,
|
leaderAddress = leader.leaderAddress,
|
||||||
leaderName = leader.leaderName,
|
leaderName = leader.leaderName,
|
||||||
category = leader.category,
|
category = leader.category,
|
||||||
|
remark = leader.remark,
|
||||||
|
website = leader.website,
|
||||||
copyTradingCount = copyTradingCount,
|
copyTradingCount = copyTradingCount,
|
||||||
createdAt = leader.createdAt,
|
createdAt = leader.createdAt,
|
||||||
updatedAt = leader.updatedAt
|
updatedAt = leader.updatedAt
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ package com.wrbug.polymarketbot.service
|
|||||||
import com.wrbug.polymarketbot.dto.AccountPositionDto
|
import com.wrbug.polymarketbot.dto.AccountPositionDto
|
||||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
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.AccountRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
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.toSafeBigDecimal
|
||||||
|
import com.wrbug.polymarketbot.util.multi
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import org.springframework.context.MessageSource
|
import org.springframework.context.MessageSource
|
||||||
import org.springframework.context.i18n.LocaleContextHolder
|
import org.springframework.context.i18n.LocaleContextHolder
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -19,12 +25,16 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
/**
|
/**
|
||||||
* 仓位检查服务
|
* 仓位检查服务
|
||||||
* 负责检查待赎回仓位和未卖出订单,并执行相应的处理逻辑
|
* 负责检查待赎回仓位和未卖出订单,并执行相应的处理逻辑
|
||||||
|
* 订阅 PositionPollingService 的事件,处理仓位检查逻辑
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class PositionCheckService(
|
class PositionCheckService(
|
||||||
|
private val positionPollingService: PositionPollingService,
|
||||||
private val accountService: AccountService,
|
private val accountService: AccountService,
|
||||||
private val copyTradingRepository: CopyTradingRepository,
|
private val copyTradingRepository: CopyTradingRepository,
|
||||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||||
|
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||||
|
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||||
private val systemConfigService: SystemConfigService,
|
private val systemConfigService: SystemConfigService,
|
||||||
private val relayClientService: RelayClientService,
|
private val relayClientService: RelayClientService,
|
||||||
private val telegramNotificationService: TelegramNotificationService?,
|
private val telegramNotificationService: TelegramNotificationService?,
|
||||||
@@ -34,8 +44,9 @@ class PositionCheckService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
||||||
|
|
||||||
// 协程作用域,用于缓存清理任务
|
// 协程作用域,用于订阅事件和缓存清理任务
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
private var subscriptionJob: Job? = null
|
||||||
|
|
||||||
// 记录已发送通知的仓位(避免重复推送)
|
// 记录已发送通知的仓位(避免重复推送)
|
||||||
private val notifiedRedeemablePositions = ConcurrentHashMap<String, Long>() // "accountId_marketId_outcomeIndex" -> lastNotificationTime
|
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 notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
|
||||||
|
|
||||||
|
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(启动缓存清理任务)
|
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
||||||
*/
|
*/
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun init() {
|
fun init() {
|
||||||
|
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
|
||||||
|
startSubscription()
|
||||||
startCacheCleanup()
|
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:处理待赎回仓位
|
* 逻辑1:处理待赎回仓位
|
||||||
* 如果有待赎回的仓位,检查是否开启了自动赎回,是否有相同仓位的订单(未卖出的订单)
|
* 按照以下逻辑处理:
|
||||||
* 如果有的话,在仓位赎回成功后以该订单卖出逻辑更新所有订单状态(未卖出)
|
* 1. 无待赎回仓位:跳过
|
||||||
* 如果未开启自动赎回,则发送tg通知,并且记录(内存缓存)该仓位,避免重复发送
|
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||||
|
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||||
*/
|
*/
|
||||||
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
||||||
try {
|
try {
|
||||||
|
// 1. 无待赎回仓位:跳过
|
||||||
|
if (redeemablePositions.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 检查系统级别的自动赎回配置
|
// 检查系统级别的自动赎回配置
|
||||||
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
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 }
|
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||||
|
|
||||||
for ((accountId, positions) in positionsByAccount) {
|
for ((accountId, positions) in positionsByAccount) {
|
||||||
@@ -139,90 +235,52 @@ class PositionCheckService(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集所有有未卖出订单的仓位,按账户分组一次性处理
|
// 先执行赎回(不查找订单)
|
||||||
val positionsWithOrders = mutableListOf<Pair<AccountPositionDto, List<CopyOrderTracking>>>()
|
val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest(
|
||||||
val positionsWithoutOrders = mutableListOf<AccountPositionDto>()
|
positions = positions.map { position ->
|
||||||
|
com.wrbug.polymarketbot.dto.AccountRedeemPositionItem(
|
||||||
for (position in positions) {
|
accountId = accountId,
|
||||||
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
marketId = position.marketId,
|
||||||
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
outcomeIndex = position.outcomeIndex ?: 0,
|
||||||
for (copyTrading in copyTradings) {
|
side = position.side
|
||||||
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 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(
|
for (position in positions) {
|
||||||
onSuccess = { response ->
|
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
||||||
logger.info("自动赎回成功: accountId=$accountId, redeemedCount=${positionsWithOrders.size}, totalValue=${response.totalRedeemedValue}")
|
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
||||||
// 在仓位赎回成功后,以该订单卖出逻辑更新所有订单状态(未卖出)
|
for (copyTrading in copyTradings) {
|
||||||
for ((position, orders) in positionsWithOrders) {
|
if (position.outcomeIndex != null) {
|
||||||
updateOrdersAsSoldAfterRedeem(orders, position)
|
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||||
|
copyTrading.id!!,
|
||||||
|
position.marketId,
|
||||||
|
position.outcomeIndex
|
||||||
|
)
|
||||||
|
unmatchedOrders.addAll(orders)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onFailure = { e ->
|
|
||||||
logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
} else {
|
// 如果有未卖出订单,更新订单状态
|
||||||
// 未开启自动赎回或未配置 API Key,发送通知
|
if (unmatchedOrders.isNotEmpty()) {
|
||||||
for ((position, _) in positionsWithOrders) {
|
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId)
|
||||||
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
|
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
|
||||||
if (!autoRedeemEnabled) {
|
if (copyTradingId != null) {
|
||||||
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
|
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
|
||||||
} else {
|
|
||||||
// API Key 未配置
|
|
||||||
for (copyTrading in copyTradings) {
|
|
||||||
checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 记录已发送通知的仓位(避免重复发送)
|
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
||||||
@@ -274,17 +332,15 @@ class PositionCheckService(
|
|||||||
if (position == null) {
|
if (position == null) {
|
||||||
// 仓位不存在,更新所有订单状态为已卖出
|
// 仓位不存在,更新所有订单状态为已卖出
|
||||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||||
updateOrdersAsSold(orders, currentPrice)
|
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
|
||||||
} else {
|
} else {
|
||||||
// 有仓位,检查仓位数量是否小于所有未卖出订单数量总和
|
// 有仓位,按订单下单顺序(FIFO)更新状态
|
||||||
|
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
|
||||||
|
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
|
||||||
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||||
val positionQuantity = position.quantity.toSafeBigDecimal()
|
val positionQuantity = position.quantity.toSafeBigDecimal()
|
||||||
|
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||||
if (positionQuantity < totalUnmatchedQuantity) {
|
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
|
||||||
// 仓位数量小于订单数量总和,按订单下单顺序(FIFO)更新状态
|
|
||||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
|
||||||
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,11 +376,12 @@ class PositionCheckService(
|
|||||||
*/
|
*/
|
||||||
private suspend fun updateOrdersAsSoldAfterRedeem(
|
private suspend fun updateOrdersAsSoldAfterRedeem(
|
||||||
orders: List<CopyOrderTracking>,
|
orders: List<CopyOrderTracking>,
|
||||||
position: AccountPositionDto
|
position: AccountPositionDto,
|
||||||
|
copyTradingId: Long
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val currentPrice = getCurrentMarketPrice(position.marketId, position.outcomeIndex ?: 0)
|
val currentPrice = getCurrentMarketPrice(position.marketId, position.outcomeIndex ?: 0)
|
||||||
updateOrdersAsSold(orders, currentPrice)
|
updateOrdersAsSold(orders, currentPrice, copyTradingId, position.marketId, position.outcomeIndex ?: 0)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新订单状态为已卖出失败: ${e.message}", e)
|
logger.error("更新订单状态为已卖出失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -332,21 +389,85 @@ class PositionCheckService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新订单状态为已卖出(使用当前最新价)
|
* 更新订单状态为已卖出(使用当前最新价)
|
||||||
|
* 同时创建卖出记录和匹配明细,用于统计
|
||||||
*/
|
*/
|
||||||
private suspend fun updateOrdersAsSold(
|
private suspend fun updateOrdersAsSold(
|
||||||
orders: List<CopyOrderTracking>,
|
orders: List<CopyOrderTracking>,
|
||||||
sellPrice: BigDecimal
|
sellPrice: BigDecimal,
|
||||||
|
copyTradingId: Long,
|
||||||
|
marketId: String,
|
||||||
|
outcomeIndex: Int
|
||||||
) {
|
) {
|
||||||
|
if (orders.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 计算总匹配数量和总盈亏
|
||||||
|
var totalMatchedQuantity = BigDecimal.ZERO
|
||||||
|
var totalRealizedPnl = BigDecimal.ZERO
|
||||||
|
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||||
|
|
||||||
for (order in orders) {
|
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.remainingQuantity = BigDecimal.ZERO
|
||||||
order.status = "fully_matched"
|
order.status = "fully_matched"
|
||||||
order.updatedAt = System.currentTimeMillis()
|
order.updatedAt = System.currentTimeMillis()
|
||||||
copyOrderTrackingRepository.save(order)
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新订单状态为已卖出异常: ${e.message}", e)
|
logger.error("更新订单状态为已卖出异常: ${e.message}", e)
|
||||||
@@ -356,15 +477,26 @@ class PositionCheckService(
|
|||||||
/**
|
/**
|
||||||
* 按 FIFO 顺序更新订单状态为已卖出
|
* 按 FIFO 顺序更新订单状态为已卖出
|
||||||
* 仓位数量小于订单数量总和时,按订单下单顺序更新
|
* 仓位数量小于订单数量总和时,按订单下单顺序更新
|
||||||
|
* 同时创建卖出记录和匹配明细,用于统计
|
||||||
*/
|
*/
|
||||||
private suspend fun updateOrdersAsSoldByFIFO(
|
private suspend fun updateOrdersAsSoldByFIFO(
|
||||||
orders: List<CopyOrderTracking>,
|
orders: List<CopyOrderTracking>,
|
||||||
availableQuantity: BigDecimal,
|
availableQuantity: BigDecimal,
|
||||||
sellPrice: BigDecimal
|
sellPrice: BigDecimal,
|
||||||
|
copyTradingId: Long,
|
||||||
|
marketId: String,
|
||||||
|
outcomeIndex: Int
|
||||||
) {
|
) {
|
||||||
|
if (orders.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 订单已经按 createdAt ASC 排序(FIFO)
|
// 订单已经按 createdAt ASC 排序(FIFO)
|
||||||
var remaining = availableQuantity
|
var remaining = availableQuantity
|
||||||
|
var totalMatchedQuantity = BigDecimal.ZERO
|
||||||
|
var totalRealizedPnl = BigDecimal.ZERO
|
||||||
|
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||||
|
|
||||||
for (order in orders) {
|
for (order in orders) {
|
||||||
if (remaining <= BigDecimal.ZERO) {
|
if (remaining <= BigDecimal.ZERO) {
|
||||||
@@ -375,6 +507,25 @@ class PositionCheckService(
|
|||||||
val toMatch = minOf(orderRemaining, remaining)
|
val toMatch = minOf(orderRemaining, remaining)
|
||||||
|
|
||||||
if (toMatch > BigDecimal.ZERO) {
|
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.matchedQuantity = order.matchedQuantity.add(toMatch)
|
||||||
order.remainingQuantity = order.remainingQuantity.subtract(toMatch)
|
order.remainingQuantity = order.remainingQuantity.subtract(toMatch)
|
||||||
|
|
||||||
@@ -393,6 +544,35 @@ class PositionCheckService(
|
|||||||
logger.info("按 FIFO 更新订单状态: orderId=${order.buyOrderId}, matched=$toMatch, remaining=${order.remainingQuantity}")
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("按 FIFO 更新订单状态异常: ${e.message}", e)
|
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 jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓位推送服务
|
* 仓位推送服务
|
||||||
* 轮询仓位接口,比较差异并推送增量更新
|
* 订阅 PositionPollingService 的事件,推送给 WebSocket 客户端
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class PositionPushService(
|
class PositionPushService(
|
||||||
private val accountService: AccountService,
|
private val positionPollingService: PositionPollingService,
|
||||||
private val positionCheckService: PositionCheckService
|
private val accountService: AccountService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionPushService::class.java)
|
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>()
|
private val clientCallbacks = ConcurrentHashMap<String, (PositionPushMessage) -> Unit>()
|
||||||
|
|
||||||
@@ -36,18 +32,18 @@ class PositionPushService(
|
|||||||
|
|
||||||
// 协程作用域和任务
|
// 协程作用域和任务
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
private var pollingJob: Job? = null
|
private var subscriptionJob: Job? = null
|
||||||
|
|
||||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(后端启动时直接启动轮询)
|
* 初始化服务(订阅 PositionPollingService 的事件)
|
||||||
*/
|
*/
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun init() {
|
fun init() {
|
||||||
logger.info("PositionPushService 初始化,启动仓位轮询任务")
|
logger.info("PositionPushService 初始化,订阅仓位轮训事件")
|
||||||
startPolling()
|
startSubscription()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,8 +52,8 @@ class PositionPushService(
|
|||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
pollingJob?.cancel()
|
subscriptionJob?.cancel()
|
||||||
pollingJob = null
|
subscriptionJob = null
|
||||||
}
|
}
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
@@ -78,27 +74,23 @@ class PositionPushService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册客户端会话(兼容旧接口)
|
* 注册客户端会话(兼容旧接口)
|
||||||
* 轮询任务已在后端启动时启动,这里只需要注册回调
|
|
||||||
*/
|
*/
|
||||||
fun registerSession(sessionId: String, callback: (PositionPushMessage) -> Unit) {
|
fun registerSession(sessionId: String, callback: (PositionPushMessage) -> Unit) {
|
||||||
logger.info("注册仓位推送客户端会话: $sessionId")
|
logger.info("注册仓位推送客户端会话: $sessionId")
|
||||||
|
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
clientCallbacks[sessionId] = callback
|
clientCallbacks[sessionId] = callback
|
||||||
// 轮询任务已在后端启动时启动,不需要在这里启动
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销客户端会话(兼容旧接口)
|
* 注销客户端会话(兼容旧接口)
|
||||||
* 轮询任务持续运行,不因客户端断开而停止
|
|
||||||
*/
|
*/
|
||||||
fun unregisterSession(sessionId: String) {
|
fun unregisterSession(sessionId: String) {
|
||||||
logger.info("注销仓位推送客户端会话: $sessionId")
|
logger.info("注销仓位推送客户端会话: $sessionId")
|
||||||
|
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
clientCallbacks.remove(sessionId)
|
clientCallbacks.remove(sessionId)
|
||||||
// 轮询任务持续运行,不停止
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,179 +126,59 @@ class PositionPushService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动轮询任务
|
* 启动订阅任务(订阅 PositionPollingService 的事件)
|
||||||
*/
|
*/
|
||||||
private fun startPolling() {
|
private fun startSubscription() {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
// 如果已经有轮询任务在运行,先取消
|
// 如果已经有订阅任务在运行,先取消
|
||||||
pollingJob?.cancel()
|
subscriptionJob?.cancel()
|
||||||
|
|
||||||
// 启动新的轮询任务
|
// 启动新的订阅任务(使用专门的线程,避免阻塞)
|
||||||
pollingJob = scope.launch {
|
subscriptionJob = scope.launch(Dispatchers.IO) {
|
||||||
while (isActive) {
|
try {
|
||||||
try {
|
// 订阅仓位轮训事件
|
||||||
pollAndPush()
|
positionPollingService.subscribe { positions ->
|
||||||
} catch (e: Exception) {
|
try {
|
||||||
logger.error("轮询仓位数据失败: ${e.message}", e)
|
handlePositionUpdate(positions)
|
||||||
}
|
} catch (e: Exception) {
|
||||||
delay(pollingInterval)
|
logger.error("处理仓位更新事件失败: ${e.message}", e)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止轮询任务
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
// 仓位检查逻辑(复用仓位轮询)
|
logger.error("订阅仓位轮训事件失败: ${e.message}", e)
|
||||||
// 处理待赎回仓位和未卖出订单
|
|
||||||
positionCheckService.checkPositions(positions.currentPositions)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("轮询仓位数据异常: ${e.message}", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算增量更新
|
* 处理仓位更新事件
|
||||||
* 返回 null 表示没有变化
|
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
|
||||||
*/
|
*/
|
||||||
private fun calculateIncremental(
|
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
|
||||||
newCurrentPositions: List<AccountPositionDto>,
|
// 更新快照
|
||||||
newHistoryPositions: List<AccountPositionDto>
|
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
|
||||||
): IncrementalUpdate? {
|
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
|
||||||
val newCurrentMap = newCurrentPositions.associateBy { it.getPositionKey() }
|
|
||||||
val newHistoryMap = newHistoryPositions.associateBy { it.getPositionKey() }
|
|
||||||
|
|
||||||
// 找出新增或更新的当前仓位
|
// 向所有订阅者发送全量数据
|
||||||
val updatedCurrentPositions = mutableListOf<AccountPositionDto>()
|
if (clientCallbacks.isNotEmpty()) {
|
||||||
newCurrentMap.forEach { (key, newPos) ->
|
val message = PositionPushMessage(
|
||||||
val oldPos = lastCurrentPositions[key]
|
type = PositionPushMessageType.FULL,
|
||||||
if (oldPos == null || hasChanged(oldPos, newPos)) {
|
timestamp = System.currentTimeMillis(),
|
||||||
updatedCurrentPositions.add(newPos)
|
currentPositions = positions.currentPositions,
|
||||||
}
|
historyPositions = positions.historyPositions
|
||||||
}
|
)
|
||||||
|
|
||||||
// 找出新增或更新的历史仓位
|
// 推送给所有连接的客户端(在专门的线程中执行,避免阻塞)
|
||||||
val updatedHistoryPositions = mutableListOf<AccountPositionDto>()
|
scope.launch(Dispatchers.IO) {
|
||||||
newHistoryMap.forEach { (key, newPos) ->
|
clientCallbacks.values.forEach { callback ->
|
||||||
val oldPos = lastHistoryPositions[key]
|
try {
|
||||||
if (oldPos == null || hasChanged(oldPos, newPos)) {
|
callback(message)
|
||||||
updatedHistoryPositions.add(newPos)
|
} catch (e: Exception) {
|
||||||
}
|
logger.error("推送全量数据失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找出已删除的仓位(从当前仓位变为历史仓位,或完全删除)
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查上次的历史仓位是否还在历史仓位列表中
|
|
||||||
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 systemConfigRepository: SystemConfigRepository,
|
||||||
private val cryptoUtils: CryptoUtils
|
private val cryptoUtils: CryptoUtils
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(SystemConfigService::class.java)
|
private val logger = LoggerFactory.getLogger(SystemConfigService::class.java)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CONFIG_KEY_BUILDER_API_KEY = "builder.api_key"
|
const val CONFIG_KEY_BUILDER_API_KEY = "builder.api_key"
|
||||||
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
||||||
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
||||||
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统配置
|
* 获取系统配置
|
||||||
*/
|
*/
|
||||||
@@ -35,15 +35,15 @@ class SystemConfigService(
|
|||||||
val builderSecret = getConfigValue(CONFIG_KEY_BUILDER_SECRET)
|
val builderSecret = getConfigValue(CONFIG_KEY_BUILDER_SECRET)
|
||||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||||
val autoRedeem = isAutoRedeemEnabled()
|
val autoRedeem = isAutoRedeemEnabled()
|
||||||
|
|
||||||
return SystemConfigDto(
|
return SystemConfigDto(
|
||||||
builderApiKeyConfigured = builderApiKey != null,
|
builderApiKeyConfigured = builderApiKey != null,
|
||||||
builderSecretConfigured = builderSecret != null,
|
builderSecretConfigured = builderSecret != null,
|
||||||
builderPassphraseConfigured = builderPassphrase != null,
|
builderPassphraseConfigured = builderPassphrase != null,
|
||||||
autoRedeem = autoRedeem
|
autoRedeemEnabled = autoRedeem
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 Builder API Key 配置
|
* 更新 Builder API Key 配置
|
||||||
*/
|
*/
|
||||||
@@ -61,7 +61,7 @@ class SystemConfigService(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Builder Secret
|
// 更新 Builder Secret
|
||||||
if (request.builderSecret != null) {
|
if (request.builderSecret != null) {
|
||||||
updateConfigValue(
|
updateConfigValue(
|
||||||
@@ -73,7 +73,7 @@ class SystemConfigService(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Builder Passphrase
|
// 更新 Builder Passphrase
|
||||||
if (request.builderPassphrase != null) {
|
if (request.builderPassphrase != null) {
|
||||||
updateConfigValue(
|
updateConfigValue(
|
||||||
@@ -85,7 +85,7 @@ class SystemConfigService(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新自动赎回配置
|
// 更新自动赎回配置
|
||||||
if (request.autoRedeem != null) {
|
if (request.autoRedeem != null) {
|
||||||
updateConfigValue(
|
updateConfigValue(
|
||||||
@@ -93,29 +93,29 @@ class SystemConfigService(
|
|||||||
request.autoRedeem.toString()
|
request.autoRedeem.toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success(getSystemConfig())
|
Result.success(getSystemConfig())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新系统配置失败", e)
|
logger.error("更新系统配置失败", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取配置值(解密)
|
* 获取配置值(解密)
|
||||||
*/
|
*/
|
||||||
fun getBuilderApiKey(): String? {
|
fun getBuilderApiKey(): String? {
|
||||||
return getConfigValue(CONFIG_KEY_BUILDER_API_KEY)?.let { cryptoUtils.decrypt(it) }
|
return getConfigValue(CONFIG_KEY_BUILDER_API_KEY)?.let { cryptoUtils.decrypt(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBuilderSecret(): String? {
|
fun getBuilderSecret(): String? {
|
||||||
return getConfigValue(CONFIG_KEY_BUILDER_SECRET)?.let { cryptoUtils.decrypt(it) }
|
return getConfigValue(CONFIG_KEY_BUILDER_SECRET)?.let { cryptoUtils.decrypt(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBuilderPassphrase(): String? {
|
fun getBuilderPassphrase(): String? {
|
||||||
return getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)?.let { cryptoUtils.decrypt(it) }
|
return getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)?.let { cryptoUtils.decrypt(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Builder API Key 是否已配置
|
* 检查 Builder API Key 是否已配置
|
||||||
*/
|
*/
|
||||||
@@ -125,7 +125,7 @@ class SystemConfigService(
|
|||||||
val passphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
val passphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||||
return apiKey != null && secret != null && passphrase != null
|
return apiKey != null && secret != null && passphrase != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查自动赎回是否启用
|
* 检查自动赎回是否启用
|
||||||
*/
|
*/
|
||||||
@@ -134,10 +134,10 @@ class SystemConfigService(
|
|||||||
return when (autoRedeemValue?.lowercase()) {
|
return when (autoRedeemValue?.lowercase()) {
|
||||||
"true" -> true
|
"true" -> true
|
||||||
"false" -> false
|
"false" -> false
|
||||||
else -> true // 默认开启
|
else -> false // 默认开启
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新自动赎回配置
|
* 更新自动赎回配置
|
||||||
*/
|
*/
|
||||||
@@ -151,14 +151,14 @@ class SystemConfigService(
|
|||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取配置值(原始值,加密存储)
|
* 获取配置值(原始值,加密存储)
|
||||||
*/
|
*/
|
||||||
private fun getConfigValue(configKey: String): String? {
|
private fun getConfigValue(configKey: String): String? {
|
||||||
return systemConfigRepository.findByConfigKey(configKey)?.configValue
|
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 CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||||
import FilteredOrdersList from './pages/FilteredOrdersList'
|
import FilteredOrdersList from './pages/FilteredOrdersList'
|
||||||
import SystemSettings from './pages/SystemSettings'
|
import SystemSettings from './pages/SystemSettings'
|
||||||
import LanguageSettings from './pages/LanguageSettings'
|
|
||||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
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 { wsManager } from './services/websocket'
|
||||||
import type { OrderPushMessage } from './types'
|
import type { OrderPushMessage } from './types'
|
||||||
import { apiService } from './services/api'
|
import { apiService } from './services/api'
|
||||||
@@ -264,11 +260,7 @@ function App() {
|
|||||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></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/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 />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
TwitterOutlined,
|
TwitterOutlined,
|
||||||
GlobalOutlined,
|
CheckCircleOutlined
|
||||||
CheckCircleOutlined,
|
|
||||||
NotificationOutlined,
|
|
||||||
KeyOutlined
|
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -120,37 +117,17 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
{
|
{
|
||||||
key: '/system-settings',
|
key: '/system-settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
label: t('menu.systemSettings'),
|
label: t('menu.systemSettings') || '系统管理',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
key: '/system-settings',
|
key: '/system-settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
label: t('menu.systemOverview') || '概览'
|
label: t('menu.systemOverview') || '通用设置'
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '/system-settings/language',
|
|
||||||
icon: <GlobalOutlined />,
|
|
||||||
label: t('menu.language')
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/system-settings/api-health',
|
key: '/system-settings/api-health',
|
||||||
icon: <CheckCircleOutlined />,
|
icon: <CheckCircleOutlined />,
|
||||||
label: t('menu.apiHealth')
|
label: t('menu.apiHealth') || 'API健康'
|
||||||
},
|
|
||||||
{
|
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -305,15 +305,30 @@
|
|||||||
"footer": "Please use the above pages for configuration management."
|
"footer": "Please use the above pages for configuration management."
|
||||||
},
|
},
|
||||||
"systemSettings": {
|
"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": {
|
"autoRedeem": {
|
||||||
"title": "Auto Redeem Configuration",
|
"title": "Auto Redeem Configuration",
|
||||||
"label": "Auto Redeem",
|
"label": "Enable Auto Redeem",
|
||||||
"tooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
|
"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",
|
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
|
||||||
"builderApiKeyNotConfiguredDesc": "Auto redeem feature requires Builder API Key configuration to take effect.",
|
"builderApiKeyNotConfiguredDesc": "Auto redeem feature requires Builder API Key configuration to take effect.",
|
||||||
"goToConfigure": "Go to Configure",
|
"goToConfigure": "Go to Configure",
|
||||||
"saveSuccess": "Auto redeem configuration updated",
|
"saveSuccess": "Auto redeem configuration saved",
|
||||||
"saveFailed": "Failed to update auto redeem configuration"
|
"saveFailed": "Failed to save auto redeem configuration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
@@ -394,6 +409,9 @@
|
|||||||
"leaderName": "Leader Name",
|
"leaderName": "Leader Name",
|
||||||
"walletAddress": "Wallet Address",
|
"walletAddress": "Wallet Address",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
|
"remark": "Remark",
|
||||||
|
"website": "Website",
|
||||||
|
"openWebsite": "Open Website",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"copyTradingCount": "Copy Trading Count",
|
"copyTradingCount": "Copy Trading Count",
|
||||||
"copyTradingRelations": "{{count}} copy trading relations",
|
"copyTradingRelations": "{{count}} copy trading relations",
|
||||||
@@ -405,6 +423,52 @@
|
|||||||
"deleteConfirm": "Are you sure you want to delete this Leader?",
|
"deleteConfirm": "Are you sure you want to delete this Leader?",
|
||||||
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first"
|
"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": {
|
"userList": {
|
||||||
"title": "User Management",
|
"title": "User Management",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
|||||||
@@ -169,7 +169,10 @@
|
|||||||
"lastCheck": "最后检查",
|
"lastCheck": "最后检查",
|
||||||
"healthy": "健康",
|
"healthy": "健康",
|
||||||
"unhealthy": "不健康",
|
"unhealthy": "不健康",
|
||||||
"unknown": "未知"
|
"unknown": "未知",
|
||||||
|
"normal": "正常",
|
||||||
|
"notConfigured": "未配置",
|
||||||
|
"abnormal": "异常"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"title": "代理设置",
|
"title": "代理设置",
|
||||||
@@ -217,15 +220,30 @@
|
|||||||
"footer": "请使用上述页面进行配置管理。"
|
"footer": "请使用上述页面进行配置管理。"
|
||||||
},
|
},
|
||||||
"systemSettings": {
|
"systemSettings": {
|
||||||
|
"title": "通用设置",
|
||||||
|
"language": {
|
||||||
|
"title": "多语言设置",
|
||||||
|
"currentLanguage": "当前语言",
|
||||||
|
"languageRequired": "请选择语言"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"title": "消息推送设置"
|
||||||
|
},
|
||||||
|
"relayer": {
|
||||||
|
"title": "Relayer 配置"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "代理设置"
|
||||||
|
},
|
||||||
"autoRedeem": {
|
"autoRedeem": {
|
||||||
"title": "自动赎回配置",
|
"title": "自动赎回配置",
|
||||||
"label": "自动赎回",
|
"label": "启用自动赎回",
|
||||||
"tooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
|
"tooltip": "开启后,系统将自动赎回所有账户中可赎回的仓位。需要配置 Builder API Key 才能生效。",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||||
"builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。",
|
"builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。",
|
||||||
"goToConfigure": "前往配置",
|
"goToConfigure": "前往配置",
|
||||||
"saveSuccess": "自动赎回配置已更新",
|
"saveSuccess": "自动赎回配置已保存",
|
||||||
"saveFailed": "更新自动赎回配置失败"
|
"saveFailed": "保存自动赎回配置失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
@@ -306,6 +324,9 @@
|
|||||||
"leaderName": "Leader 名称",
|
"leaderName": "Leader 名称",
|
||||||
"walletAddress": "钱包地址",
|
"walletAddress": "钱包地址",
|
||||||
"category": "分类",
|
"category": "分类",
|
||||||
|
"remark": "备注",
|
||||||
|
"website": "网站",
|
||||||
|
"openWebsite": "打开网页",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"copyTradingCount": "跟单关系数",
|
"copyTradingCount": "跟单关系数",
|
||||||
"copyTradingRelations": "{{count}} 个跟单关系",
|
"copyTradingRelations": "{{count}} 个跟单关系",
|
||||||
@@ -317,6 +338,52 @@
|
|||||||
"deleteConfirm": "确定要删除这个 Leader 吗?",
|
"deleteConfirm": "确定要删除这个 Leader 吗?",
|
||||||
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系"
|
"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": {
|
"userList": {
|
||||||
"title": "用户管理",
|
"title": "用户管理",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
|
|||||||
@@ -305,15 +305,30 @@
|
|||||||
"footer": "請使用上述頁面進行配置管理。"
|
"footer": "請使用上述頁面進行配置管理。"
|
||||||
},
|
},
|
||||||
"systemSettings": {
|
"systemSettings": {
|
||||||
|
"title": "通用設置",
|
||||||
|
"language": {
|
||||||
|
"title": "多語言設置",
|
||||||
|
"currentLanguage": "當前語言",
|
||||||
|
"languageRequired": "請選擇語言"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"title": "消息推送設置"
|
||||||
|
},
|
||||||
|
"relayer": {
|
||||||
|
"title": "Relayer 配置"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "代理設置"
|
||||||
|
},
|
||||||
"autoRedeem": {
|
"autoRedeem": {
|
||||||
"title": "自動贖回配置",
|
"title": "自動贖回配置",
|
||||||
"label": "自動贖回",
|
"label": "啟用自動贖回",
|
||||||
"tooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
|
"tooltip": "開啟後,系統將自動贖回所有賬戶中可贖回的倉位。需要配置 Builder API Key 才能生效。",
|
||||||
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
"builderApiKeyNotConfigured": "Builder API Key 未配置",
|
||||||
"builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。",
|
"builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。",
|
||||||
"goToConfigure": "前往配置",
|
"goToConfigure": "前往配置",
|
||||||
"saveSuccess": "自動贖回配置已更新",
|
"saveSuccess": "自動贖回配置已保存",
|
||||||
"saveFailed": "更新自動贖回配置失敗"
|
"saveFailed": "保存自動贖回配置失敗"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
@@ -394,6 +409,9 @@
|
|||||||
"leaderName": "Leader 名稱",
|
"leaderName": "Leader 名稱",
|
||||||
"walletAddress": "錢包地址",
|
"walletAddress": "錢包地址",
|
||||||
"category": "分類",
|
"category": "分類",
|
||||||
|
"remark": "備註",
|
||||||
|
"website": "網站",
|
||||||
|
"openWebsite": "打開網頁",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"copyTradingCount": "跟單關係數",
|
"copyTradingCount": "跟單關係數",
|
||||||
"copyTradingRelations": "{{count}} 個跟單關係",
|
"copyTradingRelations": "{{count}} 個跟單關係",
|
||||||
@@ -405,6 +423,52 @@
|
|||||||
"deleteConfirm": "確定要刪除這個 Leader 嗎?",
|
"deleteConfirm": "確定要刪除這個 Leader 嗎?",
|
||||||
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係"
|
"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": {
|
"userList": {
|
||||||
"title": "用戶管理",
|
"title": "用戶管理",
|
||||||
"username": "用戶名",
|
"username": "用戶名",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Card, Form, Input, Button, Select, message, Typography, Space } from 'a
|
|||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { isValidWalletAddress } from '../utils'
|
import { isValidWalletAddress } from '../utils'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
const { Option } = Select
|
const { Option } = Select
|
||||||
|
|
||||||
const LeaderAdd: React.FC = () => {
|
const LeaderAdd: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
@@ -21,17 +23,19 @@ const LeaderAdd: React.FC = () => {
|
|||||||
const response = await apiService.leaders.add({
|
const response = await apiService.leaders.add({
|
||||||
leaderAddress: values.leaderAddress.trim(),
|
leaderAddress: values.leaderAddress.trim(),
|
||||||
leaderName: values.leaderName?.trim() || undefined,
|
leaderName: values.leaderName?.trim() || undefined,
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
website: values.website?.trim() || undefined,
|
||||||
category: values.category || undefined
|
category: values.category || undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
message.success('添加 Leader 成功')
|
message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功')
|
||||||
navigate('/leaders')
|
navigate('/leaders')
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.msg || '添加 Leader 失败')
|
message.error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '添加 Leader 失败')
|
message.error(error.message || t('leaderAdd.addFailed') || '添加 Leader 失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -47,7 +51,7 @@ const LeaderAdd: React.FC = () => {
|
|||||||
>
|
>
|
||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<Title level={2} style={{ margin: 0 }}>添加 Leader</Title>
|
<Title level={2} style={{ margin: 0 }}>{t('leaderAdd.title') || '添加 Leader'}</Title>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -61,41 +65,68 @@ const LeaderAdd: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Leader 钱包地址"
|
label={t('leaderAdd.leaderAddress') || 'Leader 钱包地址'}
|
||||||
name="leaderAddress"
|
name="leaderAddress"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入 Leader 钱包地址' },
|
{ required: true, message: t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址' },
|
||||||
{
|
{
|
||||||
validator: (_, value) => {
|
validator: (_, value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return Promise.reject(new Error('请输入 Leader 钱包地址'))
|
return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址'))
|
||||||
}
|
}
|
||||||
if (!isValidWalletAddress(value.trim())) {
|
if (!isValidWalletAddress(value.trim())) {
|
||||||
return Promise.reject(new Error('钱包地址格式不正确(必须是 0x 开头的 42 位地址)'))
|
return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)'))
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
tooltip="被跟单者的钱包地址,系统将监控该地址的交易并自动跟单"
|
tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'}
|
||||||
>
|
>
|
||||||
<Input placeholder="0x..." style={{ fontFamily: 'monospace' }} />
|
<Input placeholder="0x..." style={{ fontFamily: 'monospace' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Leader 名称"
|
label={t('leaderAdd.leaderName') || 'Leader 名称'}
|
||||||
name="leaderName"
|
name="leaderName"
|
||||||
tooltip="可选,用于标识 Leader,方便管理"
|
tooltip={t('leaderAdd.leaderNameTooltip') || '可选,用于标识 Leader,方便管理'}
|
||||||
>
|
>
|
||||||
<Input placeholder="可选,用于标识 Leader" />
|
<Input placeholder={t('leaderAdd.leaderNamePlaceholder') || '可选,用于标识 Leader'} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="分类筛选"
|
label={t('leaderAdd.remark') || 'Leader 备注'}
|
||||||
name="category"
|
name="remark"
|
||||||
tooltip="仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)"
|
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="sports">Sports</Option>
|
||||||
<Option value="crypto">Crypto</Option>
|
<Option value="crypto">Crypto</Option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -109,10 +140,10 @@ const LeaderAdd: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
size={isMobile ? 'middle' : 'large'}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
>
|
>
|
||||||
添加 Leader
|
{t('leaderAdd.add') || '添加 Leader'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate('/leaders')}>
|
<Button onClick={() => navigate('/leaders')}>
|
||||||
取消
|
{t('leaderAdd.cancel') || '取消'}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } f
|
|||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Leader } from '../types'
|
import type { Leader } from '../types'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
const { Option } = Select
|
const { Option } = Select
|
||||||
|
|
||||||
const LeaderEdit: React.FC = () => {
|
const LeaderEdit: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
@@ -22,7 +24,7 @@ const LeaderEdit: React.FC = () => {
|
|||||||
if (leaderId) {
|
if (leaderId) {
|
||||||
fetchLeaderDetail(parseInt(leaderId))
|
fetchLeaderDetail(parseInt(leaderId))
|
||||||
} else {
|
} else {
|
||||||
message.error('Leader ID 无效')
|
message.error(t('leaderEdit.invalidId') || 'Leader ID 无效')
|
||||||
navigate('/leaders')
|
navigate('/leaders')
|
||||||
}
|
}
|
||||||
}, [leaderId, navigate])
|
}, [leaderId, navigate])
|
||||||
@@ -35,14 +37,16 @@ const LeaderEdit: React.FC = () => {
|
|||||||
const leader: Leader = response.data.data
|
const leader: Leader = response.data.data
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
leaderName: leader.leaderName || '',
|
leaderName: leader.leaderName || '',
|
||||||
|
remark: leader.remark || '',
|
||||||
|
website: leader.website || '',
|
||||||
category: leader.category || undefined
|
category: leader.category || undefined
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.msg || '获取 Leader 详情失败')
|
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
|
||||||
navigate('/leaders')
|
navigate('/leaders')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '获取 Leader 详情失败')
|
message.error(error.message || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
|
||||||
navigate('/leaders')
|
navigate('/leaders')
|
||||||
} finally {
|
} finally {
|
||||||
setFetching(false)
|
setFetching(false)
|
||||||
@@ -51,7 +55,7 @@ const LeaderEdit: React.FC = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (values: any) => {
|
const handleSubmit = async (values: any) => {
|
||||||
if (!leaderId) {
|
if (!leaderId) {
|
||||||
message.error('Leader ID 无效')
|
message.error(t('leaderEdit.invalidId') || 'Leader ID 无效')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,17 +64,19 @@ const LeaderEdit: React.FC = () => {
|
|||||||
const response = await apiService.leaders.update({
|
const response = await apiService.leaders.update({
|
||||||
leaderId: parseInt(leaderId),
|
leaderId: parseInt(leaderId),
|
||||||
leaderName: values.leaderName?.trim() || undefined,
|
leaderName: values.leaderName?.trim() || undefined,
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
website: values.website?.trim() || undefined,
|
||||||
category: values.category || undefined
|
category: values.category || undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
message.success('更新 Leader 成功')
|
message.success(t('leaderEdit.saveSuccess') || '更新 Leader 成功')
|
||||||
navigate('/leaders')
|
navigate('/leaders')
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.msg || '更新 Leader 失败')
|
message.error(response.data.msg || t('leaderEdit.saveFailed') || '更新 Leader 失败')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '更新 Leader 失败')
|
message.error(error.message || t('leaderEdit.saveFailed') || '更新 Leader 失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -94,7 +100,7 @@ const LeaderEdit: React.FC = () => {
|
|||||||
>
|
>
|
||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<Title level={2} style={{ margin: 0 }}>编辑 Leader</Title>
|
<Title level={2} style={{ margin: 0 }}>{t('leaderEdit.title') || '编辑 Leader'}</Title>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -105,19 +111,46 @@ const LeaderEdit: React.FC = () => {
|
|||||||
size={isMobile ? 'middle' : 'large'}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Leader 名称"
|
label={t('leaderEdit.leaderName') || 'Leader 名称'}
|
||||||
name="leaderName"
|
name="leaderName"
|
||||||
tooltip="可选,用于标识 Leader,方便管理"
|
tooltip={t('leaderEdit.leaderNameTooltip') || '可选,用于标识 Leader,方便管理'}
|
||||||
>
|
>
|
||||||
<Input placeholder="可选,用于标识 Leader" />
|
<Input placeholder={t('leaderEdit.leaderNamePlaceholder') || '可选,用于标识 Leader'} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="分类筛选"
|
label={t('leaderEdit.remark') || 'Leader 备注'}
|
||||||
name="category"
|
name="remark"
|
||||||
tooltip="仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)"
|
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="sports">Sports</Option>
|
||||||
<Option value="crypto">Crypto</Option>
|
<Option value="crypto">Crypto</Option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -131,10 +164,10 @@ const LeaderEdit: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
size={isMobile ? 'middle' : 'large'}
|
size={isMobile ? 'middle' : 'large'}
|
||||||
>
|
>
|
||||||
保存
|
{t('leaderEdit.save') || '保存'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate('/leaders')}>
|
<Button onClick={() => navigate('/leaders')}>
|
||||||
取消
|
{t('leaderEdit.cancel') || '取消'}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider } from 'antd'
|
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import type { Leader } from '../types'
|
import type { Leader } from '../types'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
const LeaderList: React.FC = () => {
|
const LeaderList: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -73,6 +75,16 @@ const LeaderList: React.FC = () => {
|
|||||||
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
|
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
|
||||||
) : <Tag>{t('leaderList.all') || '全部'}</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') || '跟单关系数',
|
title: t('leaderList.copyTradingCount') || '跟单关系数',
|
||||||
dataIndex: 'copyTradingCount',
|
dataIndex: 'copyTradingCount',
|
||||||
@@ -97,9 +109,19 @@ const LeaderList: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: t('common.actions') || '操作',
|
title: t('common.actions') || '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: isMobile ? 120 : 150,
|
width: isMobile ? 150 : 200,
|
||||||
render: (_: any, record: Leader) => (
|
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
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -199,6 +221,35 @@ const LeaderList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
{/* 分类和跟单关系数 */}
|
{/* 分类和跟单关系数 */}
|
||||||
@@ -221,13 +272,24 @@ const LeaderList: React.FC = () => {
|
|||||||
</div>
|
</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
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
|
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, minWidth: '80px' }}
|
||||||
>
|
>
|
||||||
{t('common.edit') || '编辑'}
|
{t('common.edit') || '编辑'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -243,7 +305,7 @@ const LeaderList: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, minWidth: '80px' }}
|
||||||
>
|
>
|
||||||
{t('common.delete') || '删除'}
|
{t('common.delete') || '删除'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -285,13 +285,13 @@ export const apiService = {
|
|||||||
/**
|
/**
|
||||||
* 添加 Leader
|
* 添加 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),
|
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 Leader
|
* 更新 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),
|
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface Leader {
|
|||||||
leaderAddress: string
|
leaderAddress: string
|
||||||
leaderName?: string
|
leaderName?: string
|
||||||
category?: string
|
category?: string
|
||||||
|
remark?: string // Leader 备注(可选)
|
||||||
|
website?: string // Leader 网站(可选)
|
||||||
copyTradingCount: number
|
copyTradingCount: number
|
||||||
totalOrders?: number
|
totalOrders?: number
|
||||||
totalPnl?: string
|
totalPnl?: string
|
||||||
@@ -772,7 +774,7 @@ export interface SystemConfig {
|
|||||||
builderApiKeyConfigured: boolean
|
builderApiKeyConfigured: boolean
|
||||||
builderSecretConfigured: boolean
|
builderSecretConfigured: boolean
|
||||||
builderPassphraseConfigured: boolean
|
builderPassphraseConfigured: boolean
|
||||||
autoRedeem: boolean // 自动赎回(系统级别配置,默认开启)
|
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user