Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8357546f3c | |||
| a62141ea5e | |||
| 9157203653 | |||
| 23fc20763a | |||
| e96a0b6279 | |||
| 85d8619fe7 | |||
| 89fb980da7 | |||
| 9f0b22fab5 | |||
| 5e544cda07 | |||
| b6d66aa81e | |||
| 62669719d9 | |||
| a6ca6cc3ff | |||
| d1f351108f | |||
| c4ac3a8fe0 | |||
| 1580b60d24 | |||
| 7bc059ec34 | |||
| c20e0138df | |||
| 20acc72ed6 | |||
| a31ed31c98 | |||
| 0625c62e36 |
@@ -109,4 +109,5 @@ __pycache__/
|
||||
# Submodules and external dependencies
|
||||
clob-client/
|
||||
builder-relayer-client/
|
||||
landing-page/
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# 🎉 v1.0.1 版本发布公告
|
||||
|
||||
## 📅 发布日期
|
||||
|
||||
2025年12月(具体日期待定)
|
||||
|
||||
## 🚀 主要更新
|
||||
|
||||
### 性能优化
|
||||
|
||||
#### 📊 订单簿请求优化
|
||||
- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
|
||||
- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
|
||||
- 订单簿只请求一次,在过滤检查中复用,提高性能
|
||||
|
||||
#### 💰 卖出价格计算优化
|
||||
- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
|
||||
- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
|
||||
- 卖出价格固定按 90% 计算,不再使用价格容忍度配置
|
||||
- 提高卖出订单的成交率
|
||||
|
||||
#### ⚙️ 价格容忍度默认值
|
||||
- 如果价格容忍度配置为 0,自动使用默认值 5%
|
||||
- 确保买入订单能够正常应用价格调整,提高成交率
|
||||
|
||||
### 代码优化
|
||||
|
||||
#### 🎯 使用枚举优化过滤逻辑
|
||||
- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
|
||||
- 使用类型安全的枚举替代字符串判断,提高代码可维护性
|
||||
- 移除无用字段 `isBuyOrder` 参数
|
||||
- 优化 `checkFilters` 方法返回值,使用数据类封装结果
|
||||
|
||||
#### 📝 代码结构优化
|
||||
- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
|
||||
- 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码
|
||||
- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
|
||||
|
||||
### 功能改进
|
||||
|
||||
#### 🗑️ 移除最小订单簿深度功能
|
||||
- 移除 `minOrderbookDepth`(最小订单簿深度)配置项
|
||||
- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
|
||||
- 更新前端界面,移除相关配置项和提示文案
|
||||
|
||||
#### 📈 优化最小订单深度逻辑
|
||||
- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
|
||||
- 不再区分买卖方向,提供更全面的市场流动性评估
|
||||
|
||||
#### 🎯 市场结算判断优化
|
||||
- 添加市场已关闭时的结算判断逻辑
|
||||
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
|
||||
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
|
||||
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题
|
||||
- ✅ 修复过滤检查中订单簿可能重复请求的问题
|
||||
- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`)
|
||||
|
||||
## 📦 如何更新
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull wrbug/polyhermes:v1.0.1
|
||||
|
||||
# 或使用 latest 标签
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 重启容器
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 源码部署
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git fetch origin
|
||||
git checkout v1.0.1
|
||||
|
||||
# 重新构建
|
||||
cd backend
|
||||
./gradlew bootJar
|
||||
|
||||
# 重启服务
|
||||
# 根据您的部署方式重启服务
|
||||
```
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 更新前端多语言文案,优化提示信息
|
||||
- 移除最小订单簿深度相关的文档说明
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.0.1
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@quant_tr
|
||||
* **Telegram 群组**:加入群组
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PolyHermes
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/quant_tr)
|
||||
[](https://x.com/polyhermes)
|
||||
|
||||
> 🌐 **Language**: [English](README_EN.md) | 中文
|
||||
|
||||
@@ -228,6 +228,25 @@ docker-compose up -d
|
||||
# image: wrbug/polyhermes:v1.0.0
|
||||
```
|
||||
|
||||
**更新 Docker 版本**:
|
||||
|
||||
```bash
|
||||
# 1. 停止当前容器
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. 拉取最新镜像
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 3. 重新启动服务
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 或更新到特定版本(例如 v1.0.1)
|
||||
# 修改 docker-compose.prod.yml 中的镜像标签为: image: wrbug/polyhermes:v1.0.1
|
||||
# 然后执行: docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
详细更新说明请参考:[部署文档 - 更新 Docker 版本](docs/zh/DEPLOYMENT.md#更新-docker-版本)
|
||||
|
||||
2. **本地构建部署(开发环境)**
|
||||
|
||||
```bash
|
||||
@@ -251,7 +270,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=80
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -318,7 +336,6 @@ cd frontend
|
||||
| `DB_USERNAME` | 数据库用户名 | `root` |
|
||||
| `DB_PASSWORD` | 数据库密码 | - |
|
||||
| `SERVER_PORT` | 后端服务端口 | `8000` |
|
||||
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` |
|
||||
| `JWT_SECRET` | JWT 密钥 | - |
|
||||
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
|
||||
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
|
||||
@@ -414,7 +431,7 @@ cd frontend
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/quant_tr)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Polymarket 官网](https://polymarket.com)
|
||||
- [Polymarket API 文档](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+21
-4
@@ -1,7 +1,7 @@
|
||||
# PolyHermes
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/quant_tr)
|
||||
[](https://x.com/polyhermes)
|
||||
|
||||
> 🌐 **Language**: English | [中文](README.md)
|
||||
|
||||
@@ -228,6 +228,25 @@ docker-compose up -d
|
||||
# image: wrbug/polyhermes:v1.0.0
|
||||
```
|
||||
|
||||
**Update Docker Version**:
|
||||
|
||||
```bash
|
||||
# 1. Stop current containers
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. Pull latest image
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 3. Restart services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Or update to specific version (e.g., v1.0.1)
|
||||
# Modify image tag in docker-compose.prod.yml to: image: wrbug/polyhermes:v1.0.1
|
||||
# Then run: docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
For detailed update instructions, please refer to: [Deployment Guide - Update Docker Version](docs/en/DEPLOYMENT.md#update-docker-version)
|
||||
|
||||
2. **Local Build Deployment (Development Environment)**
|
||||
|
||||
```bash
|
||||
@@ -251,7 +270,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=80
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -318,7 +336,6 @@ cd frontend
|
||||
| `DB_USERNAME` | Database username | `root` |
|
||||
| `DB_PASSWORD` | Database password | - |
|
||||
| `SERVER_PORT` | Backend service port | `8000` |
|
||||
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` |
|
||||
| `JWT_SECRET` | JWT secret key | - |
|
||||
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
|
||||
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
|
||||
@@ -414,7 +431,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
|
||||
## 🔗 Related Links
|
||||
|
||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/quant_tr)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Polymarket Official Website](https://polymarket.com)
|
||||
- [Polymarket API Documentation](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# v1.1.1
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 监听优化
|
||||
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
|
||||
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
|
||||
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
|
||||
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
|
||||
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
|
||||
|
||||
### 📊 市场状态查询优化
|
||||
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
|
||||
- 如果链上查询失败,自动降级到 Gamma API 查询
|
||||
- 提供更实时和准确的市场结算结果
|
||||
|
||||
### 🔕 自动订单通知优化
|
||||
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
|
||||
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
|
||||
- 减少不必要的通知,提升用户体验
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复移动端 API 健康页面缺少数据显示
|
||||
- 移动端添加 URL 地址显示
|
||||
- 移动端添加状态文本显示(正常/异常/未配置)
|
||||
- 移动端添加消息/状态信息显示
|
||||
- 移动端和桌面端显示信息保持一致
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 优化 Telegram 推送消息格式
|
||||
- 添加价格和数量截位处理:
|
||||
* 价格保留最多4位小数(截断,不四舍五入)
|
||||
* 数量保留最多2位小数(截断,不四舍五入)
|
||||
- 优化账户信息显示格式:
|
||||
* 有账户名和钱包地址时显示:账户名(0x123...123)
|
||||
* 只有账户名时显示账户名
|
||||
* 只有钱包地址时显示脱敏后的地址
|
||||
* 都没有时显示未知账户
|
||||
|
||||
### 配置优化
|
||||
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
|
||||
- 删除无用的 `position.push.polling-interval` 和 `position.push.heartbeat-timeout` 配置项
|
||||
- 修正日志配置中的包名(polyhermes -> polymarketbot)
|
||||
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md)
|
||||
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
|
||||
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
|
||||
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
|
||||
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
|
||||
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`)
|
||||
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`)
|
||||
- 优化代码结构,减少代码重复,提高可维护性
|
||||
|
||||
---
|
||||
|
||||
# v1.1.0
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 实时监听
|
||||
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
|
||||
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
|
||||
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
|
||||
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
|
||||
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
|
||||
- 跟单配置生效/失效时及时更新 WebSocket 订阅
|
||||
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
|
||||
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
|
||||
|
||||
### 📊 RPC 节点管理
|
||||
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
|
||||
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
|
||||
- 前端添加启用/禁用开关,支持实时切换节点状态
|
||||
- 健康检查只检查启用的节点,提高检查效率
|
||||
- 节点选择时自动过滤禁用的节点
|
||||
|
||||
### 💰 卖出订单价格轮询更新
|
||||
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
|
||||
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
|
||||
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
|
||||
- 清理已删除账户的订单记录
|
||||
- 支持加权平均价格计算,处理部分成交的订单
|
||||
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
|
||||
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单卖出订单的 API 凭证解密问题
|
||||
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
|
||||
- 确保卖出订单能够正常使用 API 凭证进行认证
|
||||
|
||||
### 修复 SELL 订单精度问题
|
||||
- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题:
|
||||
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
|
||||
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
|
||||
- 确保订单能够正常提交到 Polymarket API
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 添加 Docker 版本更新说明(中英文)
|
||||
- 添加链上 WebSocket 监听策略文档
|
||||
- 添加跟单逻辑总结文档
|
||||
- 更新部署文档,包含详细的版本更新步骤
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
|
||||
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
|
||||
- 优化 WebSocket 连接管理,减少不必要的连接
|
||||
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
|
||||
- 启用 Spring 定时任务功能(`@EnableScheduling`)
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 32 个文件
|
||||
- **代码变更**: +1872 行 / -1503 行
|
||||
- **主要提交**: 7 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.1
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:加入群组
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# v1.0.1
|
||||
|
||||
## 🚀 性能优化
|
||||
|
||||
### 📊 订单簿请求优化
|
||||
* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
|
||||
* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
|
||||
* 订单簿只请求一次,在过滤检查中复用,提高性能
|
||||
|
||||
### 💰 卖出价格计算优化
|
||||
* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
|
||||
* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
|
||||
* 卖出价格固定按 90% 计算,不再使用价格容忍度配置
|
||||
* 提高卖出订单的成交率
|
||||
|
||||
### ⚙️ 价格容忍度默认值
|
||||
* 如果价格容忍度配置为 0,自动使用默认值 5%
|
||||
* 确保买入订单能够正常应用价格调整,提高成交率
|
||||
|
||||
## 🔧 代码优化
|
||||
|
||||
### 🎯 使用枚举优化过滤逻辑
|
||||
* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
|
||||
* 使用类型安全的枚举替代字符串判断,提高代码可维护性
|
||||
* 移除无用字段 `isBuyOrder` 参数
|
||||
* 优化 `checkFilters` 方法返回值,使用数据类封装结果
|
||||
|
||||
### 📝 代码结构优化
|
||||
* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
|
||||
* 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码
|
||||
* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
|
||||
|
||||
## 🗑️ 功能移除
|
||||
|
||||
### 移除最小订单簿深度功能
|
||||
* 移除 `minOrderbookDepth`(最小订单簿深度)配置项
|
||||
* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
|
||||
* 更新前端界面,移除相关配置项和提示文案
|
||||
|
||||
### 优化最小订单深度逻辑
|
||||
* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
|
||||
* 不再区分买卖方向,提供更全面的市场流动性评估
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
* 修复盈亏和持仓价值计算逻辑未实现的问题
|
||||
* 修复过滤检查中订单簿可能重复请求的问题
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
* 更新前端多语言文案,优化提示信息
|
||||
* 移除最小订单簿深度相关的文档说明
|
||||
|
||||
**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1
|
||||
|
||||
@@ -154,9 +154,6 @@ SPRING_PROFILES_ACTIVE=prod
|
||||
# 服务器端口
|
||||
SERVER_PORT=8000
|
||||
|
||||
# Polygon RPC
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
|
||||
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
depends_on:
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.wrbug.polymarketbot
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
class PolymarketBotApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
@@ -29,10 +30,11 @@ data class JsonRpcRequest(
|
||||
|
||||
/**
|
||||
* JSON-RPC 响应
|
||||
* 使用 JsonElement 类型处理 result 字段,可以灵活处理字符串、对象、数组等类型
|
||||
*/
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String? = null,
|
||||
val result: String? = null,
|
||||
val result: JsonElement? = null, // 使用 JsonElement 类型,可以处理任意 JSON 类型
|
||||
val error: JsonRpcError? = null,
|
||||
val id: Int? = null
|
||||
)
|
||||
|
||||
@@ -16,12 +16,14 @@ interface PolymarketGammaApi {
|
||||
* 根据 condition ID 列表获取市场信息
|
||||
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
||||
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
||||
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
|
||||
* @param includeTag 是否包含标签信息
|
||||
* @return 市场信息数组
|
||||
*/
|
||||
@GET("/markets")
|
||||
suspend fun listMarkets(
|
||||
@Query("condition_ids") conditionIds: List<String>? = null,
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
}
|
||||
@@ -51,6 +53,9 @@ data class MarketResponse(
|
||||
val liquidityNum: Double? = null,
|
||||
val lastTradePrice: Double? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null
|
||||
val bestAsk: Double? = null,
|
||||
// 以下字段可能存在于响应中,但不在标准文档中
|
||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
|
||||
)
|
||||
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.system.AddRpcNodeRequest
|
||||
import com.wrbug.polymarketbot.service.system.NodeCheckResult
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import com.wrbug.polymarketbot.service.system.UpdateRpcNodeRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* RPC 节点管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/system/rpc-nodes")
|
||||
class RpcNodeController(
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RpcNodeController::class.java)
|
||||
|
||||
/**
|
||||
* 获取所有节点列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getAllNodes(@RequestBody request: Map<String, Any>?): ResponseEntity<ApiResponse<List<RpcNodeConfigDto>>> {
|
||||
return try {
|
||||
val nodes = rpcNodeService.getAllNodes()
|
||||
val dtos = nodes.map { it.toDto() }
|
||||
ResponseEntity.ok(ApiResponse.success(dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 RPC 节点列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "获取节点列表失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
*/
|
||||
@PostMapping("/add")
|
||||
fun addNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity<ApiResponse<RpcNodeConfigDto>> {
|
||||
return try {
|
||||
if (request.providerType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空"))
|
||||
}
|
||||
if (request.name.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空"))
|
||||
}
|
||||
|
||||
val result = rpcNodeService.addNode(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { node ->
|
||||
ResponseEntity.ok(ApiResponse.success(node.toDto()))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("添加 RPC 节点失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "添加节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("添加 RPC 节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "添加节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
fun updateNode(@RequestBody request: UpdateRpcNodeRequest): ResponseEntity<ApiResponse<RpcNodeConfigDto>> {
|
||||
return try {
|
||||
val result = rpcNodeService.updateNode(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { node ->
|
||||
ResponseEntity.ok(ApiResponse.success(node.toDto()))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新 RPC 节点失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新 RPC 节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
fun deleteNode(@RequestBody request: DeleteRpcNodeRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = rpcNodeService.deleteNode(request.id)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除 RPC 节点失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "删除节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除 RPC 节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "删除节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点优先级
|
||||
*/
|
||||
@PostMapping("/update-priority")
|
||||
fun updatePriority(@RequestBody request: UpdatePriorityRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = rpcNodeService.updatePriority(request.id, request.priority)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新节点优先级失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新优先级失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点优先级异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新优先级失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点健康状态
|
||||
*/
|
||||
@PostMapping("/check-health")
|
||||
fun checkHealth(@RequestBody request: CheckHealthRequest): ResponseEntity<ApiResponse<Any>> {
|
||||
return try {
|
||||
if (request.id != null) {
|
||||
// 检查单个节点
|
||||
val result = rpcNodeService.checkNodeHealth(request.id)
|
||||
result.fold(
|
||||
onSuccess = { checkResult ->
|
||||
ResponseEntity.ok(ApiResponse.success(checkResult.toDto()))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查节点健康状态失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "检查节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// 批量检查所有节点
|
||||
val result = rpcNodeService.checkAllNodesHealth()
|
||||
result.fold(
|
||||
onSuccess = { checkResults ->
|
||||
val dtos = checkResults.mapValues { it.value.toDto() }
|
||||
ResponseEntity.ok(ApiResponse.success(dtos))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("批量检查节点健康状态失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "批量检查节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查节点健康状态异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "检查节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验节点(添加前)
|
||||
*/
|
||||
@PostMapping("/validate")
|
||||
fun validateNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity<ApiResponse<ValidateNodeResponse>> {
|
||||
return try {
|
||||
if (request.providerType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空"))
|
||||
}
|
||||
if (request.name.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空"))
|
||||
}
|
||||
|
||||
// 临时创建节点配置以进行验证(不保存到数据库)
|
||||
// 这里直接复用 addNode 的部分逻辑,但只进行校验
|
||||
val result = rpcNodeService.addNode(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { node ->
|
||||
// 添加成功后立即删除(这只是为了校验)
|
||||
rpcNodeService.deleteNode(node.id!!)
|
||||
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||
valid = true,
|
||||
message = "节点可用",
|
||||
responseTimeMs = node.responseTimeMs
|
||||
)))
|
||||
},
|
||||
onFailure = { e ->
|
||||
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||
valid = false,
|
||||
message = e.message ?: "节点验证失败",
|
||||
responseTimeMs = null
|
||||
)))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||
valid = false,
|
||||
message = e.message ?: "节点验证失败",
|
||||
responseTimeMs = null
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 节点配置 DTO
|
||||
*/
|
||||
data class RpcNodeConfigDto(
|
||||
val id: Long?,
|
||||
val providerType: String,
|
||||
val name: String,
|
||||
val httpUrl: String,
|
||||
val wsUrl: String?,
|
||||
val apiKeyMasked: String?, // 脱敏后的 API Key
|
||||
val enabled: Boolean,
|
||||
val priority: Int,
|
||||
val lastCheckTime: Long?,
|
||||
val lastCheckStatus: String?,
|
||||
val responseTimeMs: Int?,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 节点检查结果 DTO
|
||||
*/
|
||||
data class NodeCheckResultDto(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val checkTime: Long,
|
||||
val responseTimeMs: Int?,
|
||||
val blockNumber: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证节点响应
|
||||
*/
|
||||
data class ValidateNodeResponse(
|
||||
val valid: Boolean,
|
||||
val message: String,
|
||||
val responseTimeMs: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* 删除节点请求
|
||||
*/
|
||||
data class DeleteRpcNodeRequest(
|
||||
val id: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新优先级请求
|
||||
*/
|
||||
data class UpdatePriorityRequest(
|
||||
val id: Long,
|
||||
val priority: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查健康状态请求
|
||||
*/
|
||||
data class CheckHealthRequest(
|
||||
val id: Long? = null // 如果为 null,则检查所有节点
|
||||
)
|
||||
|
||||
/**
|
||||
* 扩展函数:将 RpcNodeConfig 转换为 DTO
|
||||
*/
|
||||
private fun RpcNodeConfig.toDto(): RpcNodeConfigDto {
|
||||
return RpcNodeConfigDto(
|
||||
id = id,
|
||||
providerType = providerType,
|
||||
name = name,
|
||||
httpUrl = httpUrl,
|
||||
wsUrl = wsUrl,
|
||||
apiKeyMasked = apiKey?.let { "***" }, // 脱敏显示
|
||||
enabled = enabled,
|
||||
priority = priority,
|
||||
lastCheckTime = lastCheckTime,
|
||||
lastCheckStatus = lastCheckStatus,
|
||||
responseTimeMs = responseTimeMs,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展函数:将 NodeCheckResult 转换为 DTO
|
||||
*/
|
||||
private fun NodeCheckResult.toDto(): NodeCheckResultDto {
|
||||
return NodeCheckResultDto(
|
||||
status = status.name,
|
||||
message = message,
|
||||
checkTime = checkTime,
|
||||
responseTimeMs = responseTimeMs,
|
||||
blockNumber = blockNumber
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,9 @@ data class SystemConfigDto(
|
||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ data class CopyTradingCreateRequest(
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
||||
@@ -65,6 +68,9 @@ data class CopyTradingUpdateRequest(
|
||||
val maxSpread: String? = null,
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
||||
@@ -133,6 +139,9 @@ data class CopyTradingDto(
|
||||
val maxSpread: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||
|
||||
@@ -38,6 +38,9 @@ data class CopyOrderTracking(
|
||||
@Column(name = "leader_buy_trade_id", nullable = false, length = 100)
|
||||
val leaderBuyTradeId: String, // Leader 买入交易ID
|
||||
|
||||
@Column(name = "leader_buy_quantity", nullable = true, precision = 20, scale = 8)
|
||||
val leaderBuyQuantity: BigDecimal? = null, // Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal, // 买入数量
|
||||
|
||||
@@ -53,6 +56,9 @@ data class CopyOrderTracking(
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: String = "filled", // filled, fully_matched, partially_matched
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -84,6 +84,13 @@ data class CopyTrading(
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
// 最大仓位配置
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "max_position_count")
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
|
||||
// 新增配置字段
|
||||
@Column(name = "config_name", length = 255)
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* Polygon RPC 节点配置实体
|
||||
* 用于存储用户配置的 RPC 节点信息
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "rpc_node_config")
|
||||
data class RpcNodeConfig(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "provider_type", nullable = false, length = 50)
|
||||
val providerType: String, // 服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC
|
||||
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
val name: String, // 节点名称
|
||||
|
||||
@Column(name = "http_url", nullable = false, length = 500)
|
||||
val httpUrl: String, // HTTP RPC URL
|
||||
|
||||
@Column(name = "ws_url", length = 500)
|
||||
val wsUrl: String? = null, // WebSocket URL (可选)
|
||||
|
||||
@Column(name = "api_key", length = 200)
|
||||
val apiKey: String? = null, // API Key (加密存储)
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
var enabled: Boolean = true, // 是否启用
|
||||
|
||||
@Column(name = "priority", nullable = false)
|
||||
var priority: Int = 0, // 优先级(数字越小优先级越高)
|
||||
|
||||
@Column(name = "last_check_time")
|
||||
var lastCheckTime: Long? = null, // 最后检查时间(毫秒时间戳)
|
||||
|
||||
@Column(name = "last_check_status", length = 20)
|
||||
var lastCheckStatus: String? = null, // 最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN
|
||||
|
||||
@Column(name = "response_time_ms")
|
||||
var responseTimeMs: Int? = null, // 最后一次响应时间(毫秒)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* RPC 节点健康状态枚举
|
||||
*/
|
||||
enum class NodeHealthStatus {
|
||||
HEALTHY, // 健康
|
||||
UNHEALTHY, // 不健康
|
||||
UNKNOWN // 未知
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 节点服务商类型枚举
|
||||
*/
|
||||
enum class RpcProviderType {
|
||||
ALCHEMY,
|
||||
INFURA,
|
||||
QUICKNODE,
|
||||
CHAINSTACK,
|
||||
GETBLOCK,
|
||||
CUSTOM,
|
||||
PUBLIC
|
||||
}
|
||||
@@ -41,6 +41,9 @@ data class SellMatchRecord(
|
||||
@Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val totalRealizedPnl: BigDecimal, // 总已实现盈亏
|
||||
|
||||
@Column(name = "price_updated", nullable = false)
|
||||
var priceUpdated: Boolean = false, // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
+5
@@ -50,5 +50,10 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
* 根据买入订单ID查询订单跟踪记录
|
||||
*/
|
||||
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询未发送通知的买入订单(用于轮询更新)
|
||||
*/
|
||||
fun findByNotificationSentFalse(): List<CopyOrderTracking>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface RpcNodeConfigRepository : JpaRepository<RpcNodeConfig, Long> {
|
||||
/**
|
||||
* 查询所有已启用的节点,按优先级排序(优先级数字越小越靠前)
|
||||
*/
|
||||
fun findAllByEnabledTrueOrderByPriorityAsc(): List<RpcNodeConfig>
|
||||
|
||||
/**
|
||||
* 查询指定 ID 的已启用节点
|
||||
*/
|
||||
fun findByIdAndEnabledTrue(id: Long): RpcNodeConfig?
|
||||
|
||||
/**
|
||||
* 查询所有节点,按优先级排序
|
||||
*/
|
||||
fun findAllByOrderByPriorityAsc(): List<RpcNodeConfig>
|
||||
}
|
||||
+6
@@ -24,5 +24,11 @@ interface SellMatchRecordRepository : JpaRepository<SellMatchRecord, Long> {
|
||||
* 根据Leader卖出交易ID查询记录
|
||||
*/
|
||||
fun findByLeaderSellTradeId(leaderSellTradeId: String): SellMatchRecord?
|
||||
|
||||
/**
|
||||
* 查询所有价格未更新的卖出记录
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
fun findByPriceUpdatedFalse(): List<SellMatchRecord>
|
||||
}
|
||||
|
||||
|
||||
@@ -927,6 +927,8 @@ class AccountService(
|
||||
marketId = request.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = request.side,
|
||||
price = sellPrice, // 直接传递卖出价格
|
||||
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
clobApi = clobApi,
|
||||
|
||||
+97
-10
@@ -23,8 +23,10 @@ import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -45,7 +47,8 @@ class PositionCheckService(
|
||||
private val telegramNotificationService: TelegramNotificationService?,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val messageSource: MessageSource,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
||||
@@ -427,14 +430,45 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 获取当前市场最新价(用于更新订单卖出价)
|
||||
* 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价)
|
||||
* 优先使用链上查询获取市场结算结果,如果未结算则使用 API 查询
|
||||
* 如果市场已关闭:
|
||||
* - 该 outcome 赢了,返回 1
|
||||
* - 该 outcome 输了,返回 0
|
||||
*/
|
||||
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||
return try {
|
||||
// 先获取市场信息,检查市场是否已关闭
|
||||
// 优先从链上查询市场结算结果(实时性高)
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
onSuccess = { (payoutDenominator, payouts) ->
|
||||
// 如果 payouts 不为空,说明市场已结算
|
||||
if (payouts.isNotEmpty() && outcomeIndex < payouts.size) {
|
||||
val payout = payouts[outcomeIndex]
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
// payout > 0 表示赢了
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ONE
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
// payout == 0 表示输了
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
|
||||
}
|
||||
)
|
||||
|
||||
// 链上查询失败或市场未结算,降级到 API 查询
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
|
||||
|
||||
@@ -442,20 +476,27 @@ class PositionCheckService(
|
||||
val markets = marketResponse.body()!!
|
||||
val market = markets.firstOrNull()
|
||||
|
||||
if (market != null && market.closed == true) {
|
||||
// 市场已关闭,检查该 outcome 是赢了还是输了
|
||||
if (market != null) {
|
||||
// 检查市场是否已结束:1) closed == true 或 2) endDate 已过
|
||||
val isMarketEnded = checkIfMarketEnded(market)
|
||||
|
||||
if (isMarketEnded) {
|
||||
logger.debug("市场已结束: marketId=$marketId, closed=${market.closed}, endDate=${market.endDate}")
|
||||
// 市场已结束,检查该 outcome 是赢了还是输了
|
||||
val outcomeResult = checkOutcomeResult(market, outcomeIndex)
|
||||
when (outcomeResult) {
|
||||
OutcomeResult.WON -> {
|
||||
logger.info("市场已关闭且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
logger.info("市场已结束且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
return BigDecimal.ONE
|
||||
}
|
||||
OutcomeResult.LOST -> {
|
||||
logger.info("市场已关闭且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
logger.info("市场已结束且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
OutcomeResult.UNKNOWN -> {
|
||||
// 无法判断,继续使用正常价格逻辑
|
||||
// 无法判断,记录警告并继续使用正常价格逻辑
|
||||
logger.warn("市场已结束但无法判断 outcome 结果,使用正常价格: marketId=$marketId, outcomeIndex=$outcomeIndex, closed=${market.closed}, endDate=${market.endDate}, outcomePrices=${market.outcomePrices}, bestBid=${market.bestBid}, bestAsk=${market.bestAsk}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,6 +518,50 @@ class PositionCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查市场是否已结束
|
||||
* 判断条件:
|
||||
* 1. closed == true
|
||||
* 2. 或 endDate 已过(如果 endDate 不为空)
|
||||
*/
|
||||
private fun checkIfMarketEnded(market: com.wrbug.polymarketbot.api.MarketResponse): Boolean {
|
||||
// 1. 检查 closed 字段
|
||||
if (market.closed == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. 检查 endDate 是否已过
|
||||
val endDateStr = market.endDate
|
||||
if (endDateStr != null && endDateStr.isNotBlank()) {
|
||||
try {
|
||||
// endDate 可能是 ISO 8601 格式字符串或时间戳
|
||||
val endDate = if (endDateStr.matches(Regex("^\\d+$"))) {
|
||||
// 时间戳(秒或毫秒)
|
||||
val timestamp = endDateStr.toLong()
|
||||
// 判断是秒还是毫秒(如果小于 10^10,认为是秒)
|
||||
if (timestamp < 10000000000L) {
|
||||
timestamp * 1000 // 转换为毫秒
|
||||
} else {
|
||||
timestamp
|
||||
}
|
||||
} else {
|
||||
// ISO 8601 格式,尝试解析
|
||||
java.time.Instant.parse(endDateStr).toEpochMilli()
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now >= endDate) {
|
||||
logger.debug("市场 endDate 已过: marketId=${market.conditionId}, endDate=$endDateStr, now=$now")
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解析 endDate 失败: marketId=${market.conditionId}, endDate=$endDateStr, error=${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome 结果枚举
|
||||
*/
|
||||
@@ -628,7 +713,8 @@ class PositionCheckService(
|
||||
outcomeIndex = outcomeIndex,
|
||||
totalMatchedQuantity = totalMatchedQuantity,
|
||||
sellPrice = sellPrice,
|
||||
totalRealizedPnl = totalRealizedPnl
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
|
||||
)
|
||||
|
||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||
@@ -742,7 +828,8 @@ class PositionCheckService(
|
||||
outcomeIndex = outcomeIndex,
|
||||
totalMatchedQuantity = totalMatchedQuantity,
|
||||
sellPrice = sellPrice,
|
||||
totalRealizedPnl = totalRealizedPnl
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
|
||||
)
|
||||
|
||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||
|
||||
+108
-48
@@ -12,6 +12,7 @@ import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -26,10 +27,9 @@ import java.math.BigInteger
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val relayClientService: RelayClientService
|
||||
private val relayClientService: RelayClientService,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||
@@ -69,12 +69,9 @@ class BlockchainService(
|
||||
.create(PolymarketDataApi::class.java)
|
||||
}
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi? by lazy {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(polygonRpcUrl)
|
||||
}
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,13 +82,7 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法获取代理地址")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法获取代理地址。请在配置文件中设置 polygon.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 计算函数选择器
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
|
||||
@@ -126,7 +117,9 @@ class BlockchainService(
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexResult = rpcResponse.result?.asString
|
||||
?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 解析代理地址
|
||||
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
|
||||
@@ -147,12 +140,6 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法查询 USDC 余额")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法查询 USDC 余额。请在配置文件中设置 polygon.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
// 检查代理地址是否为空
|
||||
if (proxyAddress.isBlank()) {
|
||||
logger.error("代理地址为空,无法查询余额")
|
||||
@@ -173,7 +160,7 @@ class BlockchainService(
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
*/
|
||||
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 构建 ERC-20 balanceOf 函数调用
|
||||
// function signature: balanceOf(address) -> bytes4(0x70a08231)
|
||||
@@ -208,7 +195,9 @@ class BlockchainService(
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexBalance = rpcResponse.result?.asString
|
||||
?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 将十六进制转换为 BigDecimal(USDC 有 6 位小数)
|
||||
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
|
||||
@@ -264,13 +253,7 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法计算 tokenId")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法计算 tokenId"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 验证 outcomeIndex
|
||||
if (outcomeIndex < 0) {
|
||||
@@ -309,7 +292,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("调用 getCollectionId 失败: ${collectionIdResult.error}"))
|
||||
}
|
||||
|
||||
val collectionId = collectionIdResult.result ?: return Result.failure(Exception("getCollectionId 返回结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val collectionId = collectionIdResult.result?.asString
|
||||
?: return Result.failure(Exception("getCollectionId 返回结果为空"))
|
||||
|
||||
// 2. 调用 getPositionId(collateralToken, collectionId)
|
||||
val getPositionIdSelector = EthereumUtils.getFunctionSelector("getPositionId(address,bytes32)")
|
||||
@@ -339,7 +324,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("调用 getPositionId 失败: ${positionIdResult.error}"))
|
||||
}
|
||||
|
||||
val tokenId = positionIdResult.result ?: return Result.failure(Exception("getPositionId 返回结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val tokenId = positionIdResult.result?.asString
|
||||
?: return Result.failure(Exception("getPositionId 返回结果为空"))
|
||||
val tokenIdBigInt = EthereumUtils.decodeUint256(tokenId)
|
||||
|
||||
Result.success(tokenIdBigInt.toString())
|
||||
@@ -449,7 +436,7 @@ class BlockchainService(
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
private suspend fun getProxyNonce(proxyAddress: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取
|
||||
val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()")
|
||||
@@ -475,7 +462,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("获取 Proxy nonce 失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexNonce = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("Proxy nonce 结果为空"))
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce)
|
||||
return Result.success(nonce)
|
||||
}
|
||||
@@ -484,7 +473,7 @@ class BlockchainService(
|
||||
* 获取交易 nonce
|
||||
*/
|
||||
private suspend fun getTransactionCount(address: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionCount",
|
||||
@@ -503,7 +492,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("获取 nonce 失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexNonce = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("nonce 结果为空"))
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce)
|
||||
return Result.success(nonce)
|
||||
}
|
||||
@@ -512,7 +503,7 @@ class BlockchainService(
|
||||
* 获取 gas price
|
||||
*/
|
||||
private suspend fun getGasPrice(): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_gasPrice",
|
||||
@@ -529,7 +520,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("获取 gas price 失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexGasPrice = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("gas price 结果为空"))
|
||||
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice)
|
||||
return Result.success(gasPrice)
|
||||
}
|
||||
@@ -603,10 +596,79 @@ class BlockchainService(
|
||||
return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val txHash = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("交易哈希为空"))
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从链上查询市场条件(Condition)的结算结果
|
||||
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
|
||||
*
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @return Result<Pair<payoutDenominator, payouts>>
|
||||
* - payoutDenominator: 支付分母(通常为 1)
|
||||
* - payouts: 每个 outcome 的支付金额数组(0 或 1)
|
||||
* - 如果 payouts[outcomeIndex] == 1,表示该 outcome 赢了
|
||||
* - 如果 payouts[outcomeIndex] == 0,表示该 outcome 输了
|
||||
* - 如果 payouts 为空,表示市场尚未结算
|
||||
*/
|
||||
suspend fun getCondition(conditionId: String): Result<Pair<BigInteger, List<BigInteger>>> {
|
||||
return try {
|
||||
// 验证 conditionId 格式
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 构建 getCondition(bytes32) 函数调用
|
||||
// 函数签名: getCondition(bytes32)
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
val data = functionSelector + encodedConditionId
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexResult = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||
|
||||
// 解析 ABI 编码的返回结果
|
||||
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
|
||||
|
||||
Result.success(Pair(payoutDenominator, payouts))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询市场条件失败: conditionId=$conditionId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询交易详情(用于调试和分析)
|
||||
* @param txHash 交易哈希
|
||||
@@ -614,11 +676,7 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getTransactionDetails(txHash: String): Result<String> {
|
||||
return try {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 查询交易
|
||||
val txRequest = JsonRpcRequest(
|
||||
@@ -636,7 +694,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("查询交易失败: ${txRpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val txResult = txRpcResponse.result ?: return Result.failure(Exception("交易结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val txResult = txRpcResponse.result?.toString()
|
||||
?: return Result.failure(Exception("交易结果为空"))
|
||||
|
||||
// 查询交易回执(包含内部调用和事件日志)
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
@@ -653,7 +713,7 @@ class BlockchainService(
|
||||
val receiptResult = if (receiptRpcResponse.error != null) {
|
||||
"交易回执查询失败: ${receiptRpcResponse.error.message}"
|
||||
} else {
|
||||
receiptRpcResponse.result ?: "交易回执为空(可能还在打包中)"
|
||||
receiptRpcResponse.result?.toString() ?: "交易回执为空(可能还在打包中)"
|
||||
}
|
||||
|
||||
Result.success("交易信息:\n$txResult\n\n交易回执:\n$receiptResult")
|
||||
|
||||
+89
-2
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -16,7 +17,8 @@ import java.math.BigDecimal
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingFilterService(
|
||||
private val clobService: PolymarketClobService
|
||||
private val clobService: PolymarketClobService,
|
||||
private val accountService: AccountService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
|
||||
@@ -26,12 +28,16 @@ class CopyTradingFilterService(
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tokenId token ID(用于获取订单簿)
|
||||
* @param tradePrice Leader 交易价格,用于价格区间检查
|
||||
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
|
||||
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
* @return 过滤结果
|
||||
*/
|
||||
suspend fun checkFilters(
|
||||
copyTrading: CopyTrading,
|
||||
tokenId: String,
|
||||
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
|
||||
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
|
||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
): FilterResult {
|
||||
// 1. 价格区间检查(如果配置了价格区间)
|
||||
if (tradePrice != null) {
|
||||
@@ -76,6 +82,14 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
}
|
||||
|
||||
return FilterResult.passed(orderbook)
|
||||
}
|
||||
|
||||
@@ -184,5 +198,78 @@ class CopyTradingFilterService(
|
||||
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查仓位限制(按市场检查)
|
||||
* @param copyTrading 跟单配置
|
||||
* @param copyOrderAmount 跟单金额(USDC)
|
||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private suspend fun checkPositionLimits(
|
||||
copyTrading: CopyTrading,
|
||||
copyOrderAmount: BigDecimal,
|
||||
marketId: String
|
||||
): FilterResult {
|
||||
// 如果未配置仓位限制,直接通过
|
||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取账户的所有仓位信息
|
||||
val positionsResult = accountService.getAllPositions()
|
||||
if (positionsResult.isFailure) {
|
||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||
}
|
||||
|
||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||
|
||||
// 过滤出当前账户且该市场的仓位
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||
}
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue)
|
||||
val currentPositionValue = marketPositions.sumOf { position ->
|
||||
position.currentValue.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
return FilterResult.maxPositionValueFailed(
|
||||
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位)
|
||||
val currentPositionCount = marketPositions.size
|
||||
|
||||
// 检查:该市场的当前仓位数量 <= 最大仓位数量
|
||||
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
|
||||
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
|
||||
if (currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
} catch (e: Exception) {
|
||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
||||
// 如果检查异常,为了安全起见,不通过检查
|
||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-12
@@ -86,7 +86,9 @@ class CopyTradingService(
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount
|
||||
)
|
||||
} else {
|
||||
// 手动输入(所有字段必须提供)
|
||||
@@ -112,7 +114,9 @@ class CopyTradingService(
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,19 +143,22 @@ class CopyTradingService(
|
||||
maxSpread = config.maxSpread,
|
||||
minPrice = config.minPrice,
|
||||
maxPrice = config.maxPrice,
|
||||
maxPositionValue = config.maxPositionValue,
|
||||
maxPositionCount = config.maxPositionCount,
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: false
|
||||
)
|
||||
|
||||
val saved = copyTradingRepository.save(copyTrading)
|
||||
|
||||
// 如果跟单已启用,重新启动监听(确保状态完全同步)
|
||||
// 如果跟单已启用,更新 Leader 监听和账户监听(增量更新,不重启所有监听)
|
||||
if (saved.enabled) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.restartMonitoring()
|
||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||
monitorService.updateAccountMonitoring(saved.accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重新启动跟单监听失败", e)
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +211,8 @@ class CopyTradingService(
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
|
||||
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
@@ -211,12 +220,13 @@ class CopyTradingService(
|
||||
|
||||
val saved = copyTradingRepository.save(updated)
|
||||
|
||||
// 重新启动监听(确保状态完全同步)
|
||||
// 更新 Leader 监听和账户监听(增量更新,根据 enabled 状态决定添加或移除)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.restartMonitoring()
|
||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||
monitorService.updateAccountMonitoring(saved.accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重新启动跟单监听失败", e)
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,14 +324,17 @@ class CopyTradingService(
|
||||
val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
|
||||
|
||||
val leaderId = copyTrading.leaderId
|
||||
val accountId = copyTrading.accountId
|
||||
copyTradingRepository.delete(copyTrading)
|
||||
|
||||
// 重新启动监听(确保状态完全同步)
|
||||
// 更新 Leader 监听和账户监听(检查是否还有其他启用的跟单配置)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.restartMonitoring()
|
||||
monitorService.removeLeaderMonitoring(leaderId)
|
||||
monitorService.updateAccountMonitoring(accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重新启动跟单监听失败", e)
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +422,8 @@ class CopyTradingService(
|
||||
maxSpread = copyTrading.maxSpread?.toPlainString(),
|
||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||
maxPositionCount = copyTrading.maxPositionCount,
|
||||
configName = copyTrading.configName,
|
||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||
createdAt = copyTrading.createdAt,
|
||||
@@ -437,6 +452,8 @@ class CopyTradingService(
|
||||
val minOrderDepth: BigDecimal?,
|
||||
val maxSpread: BigDecimal?,
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?
|
||||
val maxPrice: BigDecimal?,
|
||||
val maxPositionValue: BigDecimal?,
|
||||
val maxPositionCount: Int?
|
||||
)
|
||||
}
|
||||
|
||||
+17
-1
@@ -17,7 +17,11 @@ enum class FilterStatus {
|
||||
/** 失败:价差过大 */
|
||||
FAILED_SPREAD,
|
||||
/** 失败:订单深度不足 */
|
||||
FAILED_ORDER_DEPTH
|
||||
FAILED_ORDER_DEPTH,
|
||||
/** 失败:超过最大仓位金额 */
|
||||
FAILED_MAX_POSITION_VALUE,
|
||||
/** 失败:超过最大仓位数量 */
|
||||
FAILED_MAX_POSITION_COUNT
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +77,18 @@ data class FilterResult(
|
||||
reason = reason,
|
||||
orderbook = orderbook
|
||||
)
|
||||
|
||||
/** 超过最大仓位金额 */
|
||||
fun maxPositionValueFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 超过最大仓位数量 */
|
||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||
import com.wrbug.polymarketbot.entity.SellMatchRecord
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
|
||||
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 跟单账户链上 WebSocket 监听服务
|
||||
* 通过统一服务订阅跟单账户的卖出和赎回事件
|
||||
* 用于更新订单状态,不再依赖轮询
|
||||
*/
|
||||
@Service
|
||||
class AccountOnChainMonitorService(
|
||||
private val unifiedOnChainWsService: UnifiedOnChainWsService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountOnChainMonitorService::class.java)
|
||||
|
||||
// 存储需要监听的账户:accountId -> Account
|
||||
private val monitoredAccounts = ConcurrentHashMap<Long, Account>()
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有跟单账户
|
||||
*/
|
||||
fun start(accounts: List<Account>) {
|
||||
// 如果没有账户,取消所有订阅
|
||||
if (accounts.isEmpty()) {
|
||||
logger.info("没有需要监听的跟单账户,取消所有订阅")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新账户列表
|
||||
monitoredAccounts.clear()
|
||||
accounts.forEach { account ->
|
||||
addAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户监听
|
||||
* 通过统一服务订阅该账户的地址
|
||||
*/
|
||||
fun addAccount(account: Account) {
|
||||
if (account.id == null) {
|
||||
logger.warn("账户 ID 为空,跳过: ${account.proxyAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val accountId = account.id!!
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredAccounts.containsKey(accountId)) {
|
||||
return
|
||||
}
|
||||
|
||||
monitoredAccounts[accountId] = account
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "ACCOUNT_$accountId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
subscriptionId = subscriptionId,
|
||||
address = account.proxyAddress,
|
||||
entityType = "ACCOUNT",
|
||||
entityId = accountId,
|
||||
callback = { txHash, httpClient, rpcApi ->
|
||||
handleAccountTransaction(accountId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("已添加跟单账户进行链上监听: accountId=${accountId}, address=${account.proxyAddress}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账户的交易
|
||||
*/
|
||||
private suspend fun handleAccountTransaction(accountId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
val account = monitoredAccounts[accountId] ?: return
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
timestamp = blockTimestamp,
|
||||
walletAddress = account.proxyAddress,
|
||||
erc20Transfers = erc20Transfers,
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
if (trade != null && trade.side == "SELL") {
|
||||
// 检测到卖出或赎回事件,更新订单状态
|
||||
handleAccountSellOrRedeem(account, trade)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理账户交易失败: accountId=$accountId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账户的卖出或赎回事件
|
||||
* 更新对应的订单状态
|
||||
*/
|
||||
private suspend fun handleAccountSellOrRedeem(account: Account, trade: TradeResponse) {
|
||||
try {
|
||||
// 获取该账户的所有启用的跟单配置
|
||||
val copyTradings = copyTradingRepository.findByAccountId(account.id!!)
|
||||
.filter { it.enabled }
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 trade 中已有的市场信息
|
||||
val marketId = trade.market // conditionId
|
||||
val outcomeIndex = trade.outcomeIndex ?: 0
|
||||
|
||||
// 计算卖出价格
|
||||
val sellPrice = trade.price.toSafeBigDecimal()
|
||||
|
||||
// 为每个跟单配置更新订单状态
|
||||
for (copyTrading in copyTradings) {
|
||||
// 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0)
|
||||
val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!)
|
||||
.filter {
|
||||
it.remainingQuantity > BigDecimal.ZERO &&
|
||||
it.marketId == marketId &&
|
||||
it.outcomeIndex == outcomeIndex
|
||||
}
|
||||
.sortedBy { it.createdAt } // 按创建时间排序(FIFO)
|
||||
|
||||
if (unmatchedOrders.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 卖出数量就是交易的 size
|
||||
val soldQuantity = trade.size.toSafeBigDecimal()
|
||||
|
||||
// 更新订单状态为已卖出
|
||||
updateOrdersAsSoldByFIFO(
|
||||
unmatchedOrders,
|
||||
soldQuantity,
|
||||
sellPrice,
|
||||
copyTrading.id!!,
|
||||
marketId,
|
||||
outcomeIndex
|
||||
)
|
||||
|
||||
logger.info("跟单账户卖出/赎回事件处理完成: accountId=${account.id}, copyTradingId=${copyTrading.id}, txHash=${trade.id}, soldQuantity=$soldQuantity, sellPrice=$sellPrice")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理账户卖出/赎回事件失败: accountId=${account.id}, txHash=${trade.id}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 FIFO 顺序更新订单为已卖出
|
||||
*/
|
||||
private suspend fun updateOrdersAsSoldByFIFO(
|
||||
orders: List<CopyOrderTracking>,
|
||||
soldQuantity: BigDecimal,
|
||||
sellPrice: BigDecimal,
|
||||
copyTradingId: Long,
|
||||
marketId: String,
|
||||
outcomeIndex: Int
|
||||
) {
|
||||
var remainingSoldQuantity = soldQuantity
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
var totalMatchedQuantity = BigDecimal.ZERO
|
||||
var totalRealizedPnl = BigDecimal.ZERO
|
||||
|
||||
for (order in orders) {
|
||||
if (remainingSoldQuantity <= BigDecimal.ZERO) {
|
||||
break
|
||||
}
|
||||
|
||||
val currentOrderRemaining = order.remainingQuantity.toSafeBigDecimal()
|
||||
val matchedQty = minOf(currentOrderRemaining, remainingSoldQuantity)
|
||||
|
||||
if (matchedQty <= BigDecimal.ZERO) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算盈亏
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchedQty)
|
||||
|
||||
// 创建匹配明细
|
||||
val detail = SellMatchDetail(
|
||||
matchRecordId = 0, // 稍后设置
|
||||
trackingId = order.id!!,
|
||||
buyOrderId = order.buyOrderId,
|
||||
matchedQuantity = matchedQty,
|
||||
buyPrice = buyPrice,
|
||||
sellPrice = sellPrice,
|
||||
realizedPnl = realizedPnl
|
||||
)
|
||||
matchDetails.add(detail)
|
||||
|
||||
totalMatchedQuantity = totalMatchedQuantity.add(matchedQty)
|
||||
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
|
||||
|
||||
// 更新订单状态
|
||||
order.matchedQuantity = order.matchedQuantity.add(matchedQty)
|
||||
order.remainingQuantity = currentOrderRemaining.subtract(matchedQty)
|
||||
order.status = if (order.remainingQuantity <= BigDecimal.ZERO) "fully_matched" else "partially_matched"
|
||||
order.updatedAt = System.currentTimeMillis()
|
||||
copyOrderTrackingRepository.save(order)
|
||||
|
||||
remainingSoldQuantity = remainingSoldQuantity.subtract(matchedQty)
|
||||
}
|
||||
|
||||
// 如果有匹配的订单,创建卖出记录
|
||||
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val sellOrderId = "AUTO_WS_${timestamp}_${copyTradingId}" // 区分 WS 自动卖出
|
||||
val leaderSellTradeId = "AUTO_WS_${timestamp}"
|
||||
|
||||
val matchRecord = SellMatchRecord(
|
||||
copyTradingId = copyTradingId,
|
||||
sellOrderId = sellOrderId,
|
||||
leaderSellTradeId = leaderSellTradeId,
|
||||
marketId = marketId,
|
||||
side = outcomeIndex.toString(),
|
||||
outcomeIndex = outcomeIndex,
|
||||
totalMatchedQuantity = totalMatchedQuantity,
|
||||
sellPrice = sellPrice,
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = true // WS 实时获取,直接标记为已更新
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除账户监听
|
||||
* 取消该账户的订阅
|
||||
*/
|
||||
fun removeAccount(accountId: Long) {
|
||||
monitoredAccounts.remove(accountId)
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "ACCOUNT_$accountId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
logger.info("已移除跟单账户的链上监听: accountId=$accountId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户监听状态
|
||||
*/
|
||||
fun updateAccountMonitoring(accountId: Long) {
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
if (account != null && account.isEnabled) {
|
||||
addAccount(account)
|
||||
} else {
|
||||
removeAccount(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
fun stop() {
|
||||
// 取消所有账户的订阅
|
||||
val accountIds = monitoredAccounts.keys.toList()
|
||||
for (accountId in accountIds) {
|
||||
removeAccount(accountId)
|
||||
}
|
||||
monitoredAccounts.clear()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
+85
-10
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import jakarta.annotation.PostConstruct
|
||||
@@ -12,14 +13,18 @@ import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* 跟单监听服务(主服务)
|
||||
* 管理所有Leader的交易监听(使用轮询方式)
|
||||
* 注意:WebSocket 需要认证才能订阅其他用户的交易,因此只使用轮询方式
|
||||
* 管理所有Leader的交易监听
|
||||
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
|
||||
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket)
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingMonitorService(
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val pollingService: CopyTradingPollingService
|
||||
private val accountRepository: AccountRepository,
|
||||
private val pollingService: CopyTradingPollingService,
|
||||
private val onChainWsService: OnChainWsService,
|
||||
private val accountOnChainMonitorService: AccountOnChainMonitorService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
|
||||
@@ -46,12 +51,16 @@ class CopyTradingMonitorService(
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
// 只使用轮询,不使用WebSocket
|
||||
// 停止轮询和链上 WS 监听
|
||||
pollingService.stop()
|
||||
onChainWsService.stop()
|
||||
accountOnChainMonitorService.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
*/
|
||||
suspend fun startMonitoring() {
|
||||
// 1. 获取所有启用的跟单关系
|
||||
@@ -67,14 +76,26 @@ class CopyTradingMonitorService(
|
||||
leaderRepository.findById(leaderId).orElse(null)
|
||||
}
|
||||
|
||||
// 3. 获取所有需要监听的跟单账户(去重)
|
||||
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
|
||||
val accounts = accountIds.mapNotNull { accountId ->
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
}
|
||||
|
||||
// 3. 启动轮询监听(使用 /activity 接口,不需要认证)
|
||||
// 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询
|
||||
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
|
||||
onChainWsService.start(leaders)
|
||||
|
||||
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
|
||||
pollingService.start(leaders)
|
||||
|
||||
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
accountOnChainMonitorService.start(accounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听(当创建新的跟单关系时调用)
|
||||
* 如果 Leader 已经在监听列表中,不重复添加
|
||||
*/
|
||||
suspend fun addLeaderMonitoring(leaderId: Long) {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
@@ -85,28 +106,82 @@ class CopyTradingMonitorService(
|
||||
return
|
||||
}
|
||||
|
||||
// 只使用轮询,不使用WebSocket(需要认证)
|
||||
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听(当删除跟单关系时调用)
|
||||
* 移除Leader监听(当删除跟单关系或禁用时调用)
|
||||
* 检查该 Leader 是否还有其他启用的跟单配置
|
||||
*/
|
||||
suspend fun removeLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
// 如果还有启用的跟单配置,不移除监听
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只使用轮询,不使用WebSocket
|
||||
// 没有启用的跟单配置了,移除监听
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Leader监听(当跟单配置状态改变时调用)
|
||||
* 根据当前状态决定添加或移除监听
|
||||
*/
|
||||
suspend fun updateLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return
|
||||
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
// 有启用的跟单配置,确保在监听列表中
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
|
||||
// 更新账户监听(添加该配置关联的账户)
|
||||
val accountIds = copyTradings.map { it.accountId }.distinct()
|
||||
accountIds.forEach { accountId ->
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
if (account != null) {
|
||||
accountOnChainMonitorService.addAccount(account)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有启用的跟单配置,移除监听
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户监听(当跟单配置状态改变时调用)
|
||||
* 根据当前状态决定添加或移除账户监听
|
||||
*/
|
||||
suspend fun updateAccountMonitoring(accountId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
.filter { it.enabled }
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return
|
||||
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
// 有启用的跟单配置,确保账户在监听列表中
|
||||
accountOnChainMonitorService.addAccount(account)
|
||||
} else {
|
||||
// 没有启用的跟单配置,移除账户监听
|
||||
accountOnChainMonitorService.removeAccount(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启动监听(当跟单关系状态改变时调用)
|
||||
* 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新
|
||||
*/
|
||||
suspend fun restartMonitoring() {
|
||||
// 只使用轮询,不使用WebSocket
|
||||
// 停止所有监听
|
||||
onChainWsService.stop()
|
||||
pollingService.stop()
|
||||
delay(1000) // 等待1秒
|
||||
startMonitoring()
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 链上 WebSocket 监听服务
|
||||
* 通过统一服务订阅 Leader 的链上交易
|
||||
*/
|
||||
@Service
|
||||
class OnChainWsService(
|
||||
private val unifiedOnChainWsService: UnifiedOnChainWsService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||
|
||||
// 存储需要监听的Leader:leaderId -> Leader
|
||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有 Leader
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
// 如果没有 Leader,取消所有订阅
|
||||
if (leaders.isEmpty()) {
|
||||
logger.info("没有需要监听的 Leader,取消所有订阅")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 Leader 列表
|
||||
monitoredLeaders.clear()
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
* 通过统一服务订阅该 Leader 的地址
|
||||
*/
|
||||
fun addLeader(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredLeaders.containsKey(leaderId)) {
|
||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
return
|
||||
}
|
||||
|
||||
monitoredLeaders[leaderId] = leader
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
subscriptionId = subscriptionId,
|
||||
address = leader.leaderAddress,
|
||||
entityType = "LEADER",
|
||||
entityId = leaderId,
|
||||
callback = { txHash, httpClient, rpcApi ->
|
||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Leader 的交易
|
||||
*/
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
timestamp = blockTimestamp,
|
||||
walletAddress = leader.leaderAddress,
|
||||
erc20Transfers = erc20Transfers,
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
if (trade != null) {
|
||||
// 调用 processTrade 处理交易
|
||||
copyOrderTrackingService.processTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
source = "onchain-ws"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
* 取消该 Leader 的订阅
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
fun stop() {
|
||||
// 取消所有 Leader 的订阅
|
||||
val leaderIds = monitoredLeaders.keys.toList()
|
||||
for (leaderId in leaderIds) {
|
||||
removeLeader(leaderId)
|
||||
}
|
||||
monitoredLeaders.clear()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 链上 WebSocket 工具类
|
||||
* 提取公共的交易解析、工具函数等逻辑
|
||||
*/
|
||||
object OnChainWsUtils {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OnChainWsUtils::class.java)
|
||||
|
||||
// 合约地址
|
||||
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
|
||||
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
||||
const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
|
||||
|
||||
/**
|
||||
* ERC20 Transfer 数据类
|
||||
*/
|
||||
data class Erc20Transfer(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val value: BigInteger
|
||||
)
|
||||
|
||||
/**
|
||||
* ERC1155 Transfer 数据类
|
||||
*/
|
||||
data class Erc1155Transfer(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val tokenId: BigInteger,
|
||||
val value: BigInteger
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场信息数据类
|
||||
*/
|
||||
data class MarketInfo(
|
||||
val conditionId: String,
|
||||
val outcomeIndex: Int?, // 可空,因为可能找不到对应的 tokenId
|
||||
val outcome: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析 receipt 中的 Transfer 日志
|
||||
*/
|
||||
fun parseReceiptTransfers(logs: JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
|
||||
val erc20 = mutableListOf<Erc20Transfer>()
|
||||
val erc1155 = mutableListOf<Erc1155Transfer>()
|
||||
|
||||
for (logElement in logs) {
|
||||
val log = logElement.asJsonObject
|
||||
val address = log.get("address")?.asString?.lowercase() ?: continue
|
||||
val topicsArray = log.getAsJsonArray("topics") ?: continue
|
||||
val topics = topicsArray.mapNotNull { it.asString }
|
||||
if (topics.isEmpty()) continue
|
||||
|
||||
val t0 = topics[0].lowercase()
|
||||
val data = log.get("data")?.asString ?: "0x"
|
||||
|
||||
// USDC ERC20 Transfer
|
||||
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||
val from = topicToAddress(topics[1])
|
||||
val to = topicToAddress(topics[2])
|
||||
val value = hexToBigInt(data)
|
||||
erc20.add(Erc20Transfer(from, to, value))
|
||||
continue
|
||||
}
|
||||
|
||||
// ERC1155 TransferSingle
|
||||
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
|
||||
val from = topicToAddress(topics[2])
|
||||
val to = topicToAddress(topics[3])
|
||||
val bytes = bytesFromHex(data)
|
||||
if (bytes.size >= 64) {
|
||||
val tokenId = sliceBigInt32(bytes, 0)
|
||||
val value = sliceBigInt32(bytes, 32)
|
||||
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ERC1155 TransferBatch
|
||||
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
|
||||
val from = topicToAddress(topics[2])
|
||||
val to = topicToAddress(topics[3])
|
||||
val bytes = bytesFromHex(data)
|
||||
if (bytes.size < 64) continue
|
||||
|
||||
val offIds = sliceBigInt32(bytes, 0).toInt()
|
||||
val offVals = sliceBigInt32(bytes, 32).toInt()
|
||||
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
|
||||
|
||||
val nIds = sliceBigInt32(bytes, offIds).toInt()
|
||||
val nVals = sliceBigInt32(bytes, offVals).toInt()
|
||||
if (nIds != nVals) continue
|
||||
|
||||
val idsStart = offIds + 32
|
||||
val valsStart = offVals + 32
|
||||
for (i in 0 until nIds) {
|
||||
val ib = idsStart + i * 32
|
||||
val vb = valsStart + i * 32
|
||||
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
|
||||
val tokenId = sliceBigInt32(bytes, ib)
|
||||
val value = sliceBigInt32(bytes, vb)
|
||||
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(erc20, erc1155)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Transfer 日志解析交易信息
|
||||
*/
|
||||
suspend fun parseTradeFromTransfers(
|
||||
txHash: String,
|
||||
timestamp: Long?,
|
||||
walletAddress: String,
|
||||
erc20Transfers: List<Erc20Transfer>,
|
||||
erc1155Transfers: List<Erc1155Transfer>,
|
||||
retrofitFactory: RetrofitFactory
|
||||
): TradeResponse? {
|
||||
val wallet = walletAddress.lowercase()
|
||||
|
||||
// 计算 USDC 流入和流出
|
||||
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
|
||||
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
||||
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
|
||||
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
||||
|
||||
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
|
||||
val inById = mutableMapOf<BigInteger, BigInteger>()
|
||||
val outById = mutableMapOf<BigInteger, BigInteger>()
|
||||
for (t in erc1155Transfers) {
|
||||
if (t.to.lowercase() == wallet) {
|
||||
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
||||
}
|
||||
if (t.from.lowercase() == wallet) {
|
||||
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
||||
}
|
||||
}
|
||||
|
||||
// 找到最大的流入和流出 tokenId
|
||||
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
|
||||
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
|
||||
|
||||
val (bestInId, bestInVal) = best(inById)
|
||||
val (bestOutId, bestOutVal) = best(outById)
|
||||
|
||||
// 判断交易方向
|
||||
var side: String? = null
|
||||
var asset: BigInteger? = null
|
||||
var sizeRaw = BigInteger.ZERO
|
||||
var usdcRaw = BigInteger.ZERO
|
||||
|
||||
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
|
||||
// BUY: 收到 token,支付 USDC
|
||||
side = "BUY"
|
||||
asset = bestInId
|
||||
sizeRaw = bestInVal
|
||||
usdcRaw = usdcOut
|
||||
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
|
||||
// SELL: 卖出 token,收到 USDC
|
||||
side = "SELL"
|
||||
asset = bestOutId
|
||||
sizeRaw = bestOutVal
|
||||
usdcRaw = usdcIn
|
||||
} else {
|
||||
// 无法判断交易方向
|
||||
return null
|
||||
}
|
||||
|
||||
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
|
||||
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
||||
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
||||
val price = if (size.signum() > 0) {
|
||||
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||
|
||||
// 创建 TradeResponse
|
||||
return TradeResponse(
|
||||
id = txHash,
|
||||
market = marketInfo?.conditionId ?: "",
|
||||
side = side,
|
||||
price = price.toPlainString(),
|
||||
size = size.toPlainString(),
|
||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||
user = walletAddress,
|
||||
outcomeIndex = marketInfo?.outcomeIndex,
|
||||
outcome = marketInfo?.outcome
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
* 使用 Retrofit 接口,支持 clob_token_ids 参数
|
||||
*/
|
||||
suspend fun fetchMarketByTokenId(tokenId: String, retrofitFactory: RetrofitFactory): MarketInfo? {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketsResponse = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
clobTokenIds = listOf(tokenId),
|
||||
includeTag = null
|
||||
)
|
||||
|
||||
if (!marketsResponse.isSuccessful || marketsResponse.body() == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val markets = marketsResponse.body()!!
|
||||
val market = markets.firstOrNull()
|
||||
|
||||
if (market == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析 clobTokenIds(可能是 JSON 字符串或数组)
|
||||
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
|
||||
val clobTokenIds = when {
|
||||
clobTokenIdsRaw == null -> null
|
||||
else -> {
|
||||
try {
|
||||
// 尝试解析 JSON 字符串
|
||||
val gson = com.google.gson.Gson()
|
||||
val listType = object : com.google.gson.reflect.TypeToken<List<String>>() {}.type
|
||||
gson.fromJson<List<String>>(clobTokenIdsRaw, listType)
|
||||
} catch (e: Exception) {
|
||||
// 如果不是 JSON 字符串,可能是其他格式,返回 null
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 outcomes(可能是 JSON 字符串或数组)
|
||||
val outcomes = com.wrbug.polymarketbot.util.JsonUtils.parseStringArray(market.outcomes)
|
||||
|
||||
// 查找 tokenId 在 clobTokenIds 中的索引
|
||||
val outcomeIndex = clobTokenIds?.indexOfFirst {
|
||||
it.equals(tokenId, ignoreCase = true)
|
||||
}?.takeIf { it >= 0 }
|
||||
|
||||
// 获取 outcome 名称
|
||||
val outcome = if (outcomeIndex != null && outcomes.isNotEmpty() && outcomeIndex < outcomes.size) {
|
||||
outcomes[outcomeIndex]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val conditionId = market.conditionId ?: return null
|
||||
|
||||
MarketInfo(
|
||||
conditionId = conditionId,
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcome = outcome
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询市场信息失败: tokenId=$tokenId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块时间戳
|
||||
*/
|
||||
suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
|
||||
return try {
|
||||
val blockRequest = JsonRpcRequest(
|
||||
method = "eth_getBlockByNumber",
|
||||
params = listOf(blockNumber, false)
|
||||
)
|
||||
|
||||
val blockResponse = rpcApi.call(blockRequest)
|
||||
if (blockResponse.isSuccessful && blockResponse.body() != null) {
|
||||
val blockRpcResponse = blockResponse.body()!!
|
||||
if (blockRpcResponse.error == null && blockRpcResponse.result != null) {
|
||||
val blockJson = blockRpcResponse.result.asJsonObject
|
||||
val timestampHex = blockJson.get("timestamp")?.asString
|
||||
if (timestampHex != null) {
|
||||
BigInteger(timestampHex.removePrefix("0x"), 16).toLong() * 1000 // 转换为毫秒
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取区块时间戳失败: blockNumber=$blockNumber, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:地址转 topic(32字节,左对齐)
|
||||
*/
|
||||
fun addressToTopic32(address: String): String {
|
||||
val clean = address.removePrefix("0x").lowercase()
|
||||
return "0x" + clean.padStart(64, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:topic 转地址
|
||||
*/
|
||||
fun topicToAddress(topic: String): String {
|
||||
val clean = topic.removePrefix("0x").lowercase()
|
||||
return "0x" + clean.takeLast(40)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:十六进制转 BigInteger
|
||||
*/
|
||||
fun hexToBigInt(hex: String): BigInteger {
|
||||
val clean = hex.removePrefix("0x")
|
||||
return if (clean.isBlank()) BigInteger.ZERO else BigInteger(clean, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:十六进制转字节数组
|
||||
*/
|
||||
fun bytesFromHex(hex: String): ByteArray {
|
||||
val clean = hex.removePrefix("0x")
|
||||
return ByteArray(clean.length / 2) { i ->
|
||||
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:从字节数组切片 BigInteger(32字节)
|
||||
*/
|
||||
fun sliceBigInt32(bytes: ByteArray, offset: Int): BigInteger {
|
||||
if (offset + 32 > bytes.size) return BigInteger.ZERO
|
||||
val slice = bytes.sliceArray(offset until offset + 32)
|
||||
return BigInteger(1, slice)
|
||||
}
|
||||
}
|
||||
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.getProxyConfig
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 统一的链上 WebSocket 服务
|
||||
* 管理唯一的 WebSocket 连接,其他服务通过订阅的方式接收链上事件
|
||||
*/
|
||||
@Service
|
||||
class UnifiedOnChainWsService(
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java)
|
||||
|
||||
// Gson 实例,用于解析 JSON
|
||||
private val gson = Gson()
|
||||
|
||||
@Value("\${copy.trading.onchain.ws.reconnect.delay:3000}")
|
||||
private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// WebSocket 连接(唯一)
|
||||
private var webSocket: WebSocket? = null
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
// 订阅ID计数器(用于请求 ID)
|
||||
private var requestIdCounter = 0
|
||||
|
||||
// 连接任务(确保只有一个连接任务在运行)
|
||||
private var connectionJob: Job? = null
|
||||
|
||||
// 存储所有订阅:subscriptionId -> 订阅信息
|
||||
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
|
||||
|
||||
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
|
||||
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
|
||||
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
|
||||
|
||||
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
|
||||
// 用于在收到日志通知时,知道是哪个订阅
|
||||
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* 订阅信息
|
||||
*/
|
||||
data class SubscriptionInfo(
|
||||
val subscriptionId: String, // 订阅的唯一标识
|
||||
val address: String, // 要监听的地址(Leader 地址或账户代理地址)
|
||||
val entityType: String, // 实体类型:LEADER 或 ACCOUNT
|
||||
val entityId: Long, // 实体 ID(Leader ID 或 Account ID)
|
||||
val callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit // 回调函数
|
||||
)
|
||||
|
||||
/**
|
||||
* 订阅地址监听
|
||||
* @param subscriptionId 订阅的唯一标识(建议格式:"{entityType}_{entityId}")
|
||||
* @param address 要监听的地址(Leader 地址或账户代理地址)
|
||||
* @param entityType 实体类型:LEADER 或 ACCOUNT
|
||||
* @param entityId 实体 ID(Leader ID 或 Account ID)
|
||||
* @param callback 回调函数,当检测到该地址的交易时调用
|
||||
* @return 是否订阅成功
|
||||
*/
|
||||
fun subscribe(
|
||||
subscriptionId: String,
|
||||
address: String,
|
||||
entityType: String,
|
||||
entityId: Long,
|
||||
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
|
||||
): Boolean {
|
||||
try {
|
||||
// 如果已经订阅,先取消
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
unsubscribe(subscriptionId)
|
||||
}
|
||||
|
||||
// 创建订阅信息
|
||||
val subscription = SubscriptionInfo(
|
||||
subscriptionId = subscriptionId,
|
||||
address = address.lowercase(),
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
callback = callback
|
||||
)
|
||||
|
||||
subscriptions[subscriptionId] = subscription
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (isConnected) {
|
||||
scope.launch {
|
||||
subscribeAddress(subscription)
|
||||
}
|
||||
} else {
|
||||
// 如果未连接,启动连接
|
||||
startConnection()
|
||||
}
|
||||
|
||||
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅地址监听失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
fun unsubscribe(subscriptionId: String) {
|
||||
val subscription = subscriptions.remove(subscriptionId)
|
||||
|
||||
if (subscription != null && isConnected) {
|
||||
// 取消该订阅的所有 RPC 订阅
|
||||
scope.launch {
|
||||
// 查找该订阅的所有 RPC subscriptionId
|
||||
val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries
|
||||
.filter { it.value == subscriptionId }
|
||||
.map { it.key }
|
||||
|
||||
for (rpcSubId in rpcSubscriptionIds) {
|
||||
unsubscribeRpc(rpcSubId)
|
||||
rpcSubscriptionIdToSubscriptionId.remove(rpcSubId)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("取消订阅: subscriptionId=$subscriptionId")
|
||||
}
|
||||
|
||||
// 如果没有订阅了,停止连接
|
||||
if (subscriptions.isEmpty()) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动连接(如果还没有连接)
|
||||
*/
|
||||
private fun startConnection() {
|
||||
// 如果没有订阅,不启动连接
|
||||
if (subscriptions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果连接任务已经在运行,不重复启动
|
||||
if (connectionJob != null && connectionJob!!.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
// 启动连接任务
|
||||
connectionJob = scope.launch {
|
||||
startConnectionLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动连接循环
|
||||
*/
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
// 如果没有订阅,停止连接
|
||||
if (subscriptions.isEmpty()) {
|
||||
logger.info("没有订阅,停止连接")
|
||||
stop()
|
||||
break
|
||||
}
|
||||
|
||||
// 如果已经连接,等待断开
|
||||
if (isConnected && webSocket != null) {
|
||||
waitForDisconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取可用的 RPC 节点
|
||||
val wsUrl = rpcNodeService.getWsUrl()
|
||||
val httpUrl = rpcNodeService.getHttpUrl()
|
||||
|
||||
if (wsUrl.isBlank() || httpUrl.isBlank()) {
|
||||
logger.warn("没有可用的 RPC 节点,等待重试...")
|
||||
delay(reconnectDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)")
|
||||
|
||||
// 创建 HTTP 客户端(用于 RPC 调用)
|
||||
val httpClient = createHttpClient()
|
||||
|
||||
// 创建 RPC API 客户端
|
||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
||||
|
||||
// 连接 WebSocket
|
||||
connectWebSocket(wsUrl, httpClient, rpcApi)
|
||||
|
||||
// 等待连接建立
|
||||
waitForConnect()
|
||||
|
||||
// 如果连接成功,订阅所有地址
|
||||
if (isConnected) {
|
||||
logger.info("WebSocket 连接已建立,开始订阅")
|
||||
for (subscription in subscriptions.values) {
|
||||
subscribeAddress(subscription)
|
||||
}
|
||||
|
||||
// 等待连接断开
|
||||
waitForDisconnect()
|
||||
}
|
||||
|
||||
// 连接断开后,如果没有订阅了,不再重连
|
||||
if (subscriptions.isEmpty()) {
|
||||
logger.info("没有订阅,停止重连")
|
||||
break
|
||||
}
|
||||
|
||||
// 等待后重连
|
||||
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
|
||||
delay(reconnectDelay)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("连接异常: ${e.message}", e)
|
||||
delay(reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 客户端
|
||||
*/
|
||||
private fun createHttpClient(): OkHttpClient {
|
||||
val proxy = getProxyConfig()
|
||||
val builder = createClient()
|
||||
|
||||
if (proxy != null) {
|
||||
builder.proxy(proxy)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
// 先关闭旧连接
|
||||
webSocket?.close(1000, "重新连接")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(wsUrl)
|
||||
.build()
|
||||
|
||||
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
isConnected = true
|
||||
logger.info("链上 WebSocket 连接成功")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch {
|
||||
handleMessage(text, httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
scope.launch {
|
||||
handleMessage(bytes.utf8(), httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
|
||||
isConnected = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待连接建立
|
||||
*/
|
||||
private suspend fun waitForConnect() {
|
||||
var waited = 0L
|
||||
val timeout = 15000L // 15秒超时
|
||||
|
||||
while (!isConnected && waited < timeout) {
|
||||
delay(100)
|
||||
waited += 100
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
logger.warn("WebSocket 连接超时,等待重连")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待连接断开
|
||||
*/
|
||||
private suspend fun waitForDisconnect() {
|
||||
while (isConnected && scope.isActive) {
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅地址(为每个地址订阅 6 个事件)
|
||||
*/
|
||||
private suspend fun subscribeAddress(subscription: SubscriptionInfo) {
|
||||
if (webSocket == null || !isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
val address = subscription.address
|
||||
val walletTopic = OnChainWsUtils.addressToTopic32(address)
|
||||
val subscriptionId = subscription.subscriptionId
|
||||
|
||||
try {
|
||||
// 订阅 USDC Transfer (from wallet)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 USDC Transfer (to wallet)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferSingle (from wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferSingle (to wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferBatch (from wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferBatch (to wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId)
|
||||
|
||||
logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅日志
|
||||
*/
|
||||
private fun subscribeLogs(address: String, topics: List<String?>, subscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val params = mapOf(
|
||||
"address" to address.lowercase(),
|
||||
"topics" to topics.filterNotNull()
|
||||
)
|
||||
|
||||
val requestId = ++requestIdCounter
|
||||
requestIdToSubscriptionId[requestId] = subscriptionId
|
||||
|
||||
val request = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to requestId,
|
||||
"method" to "eth_subscribe",
|
||||
"params" to listOf("logs", params)
|
||||
)
|
||||
|
||||
val message = gson.toJson(request)
|
||||
ws.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 RPC 订阅
|
||||
*/
|
||||
private fun unsubscribeRpc(rpcSubscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val requestId = ++requestIdCounter
|
||||
val request = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to requestId,
|
||||
"method" to "eth_unsubscribe",
|
||||
"params" to listOf(rpcSubscriptionId)
|
||||
)
|
||||
|
||||
val message = gson.toJson(request)
|
||||
ws.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息
|
||||
*/
|
||||
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
val message = gson.fromJson(text, JsonObject::class.java)
|
||||
|
||||
// 处理订阅响应
|
||||
if (message.has("result") && message.has("id")) {
|
||||
val requestId = message.get("id")?.asInt
|
||||
val rpcSubscriptionId = message.get("result")?.asString
|
||||
|
||||
if (requestId != null && rpcSubscriptionId != null) {
|
||||
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
|
||||
if (subscriptionId != null) {
|
||||
// 保存 RPC subscriptionId 到订阅的映射
|
||||
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
|
||||
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 处理日志通知
|
||||
if (message.has("params")) {
|
||||
val params = message.getAsJsonObject("params")
|
||||
val subscriptionIdParam = params.get("subscription")?.asString
|
||||
val result = params.getAsJsonObject("result")
|
||||
|
||||
if (result != null) {
|
||||
val txHash = result.get("transactionHash")?.asString
|
||||
if (txHash != null && subscriptionIdParam != null) {
|
||||
// 根据 RPC subscriptionId 找到对应的订阅
|
||||
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
|
||||
if (subscriptionId != null) {
|
||||
// 处理交易,分发给对应的订阅者
|
||||
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
|
||||
} else {
|
||||
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
|
||||
processTransaction(txHash, httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易(为特定订阅)
|
||||
* 直接调用订阅的回调
|
||||
*/
|
||||
private suspend fun processTransactionForSubscription(
|
||||
txHash: String,
|
||||
subscriptionId: String,
|
||||
httpClient: OkHttpClient,
|
||||
rpcApi: EthereumRpcApi
|
||||
) {
|
||||
val subscription = subscriptions[subscriptionId] ?: return
|
||||
|
||||
try {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
} catch (e: Exception) {
|
||||
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易(为所有订阅,用于兼容)
|
||||
* 解析交易中的 Transfer 事件,分发给所有订阅者
|
||||
*/
|
||||
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 为每个订阅检查是否匹配,如果匹配则调用回调
|
||||
for (subscription in subscriptions.values) {
|
||||
val address = subscription.address
|
||||
|
||||
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
|
||||
val isInvolved = erc20Transfers.any {
|
||||
it.from.lowercase() == address || it.to.lowercase() == address
|
||||
} || erc1155Transfers.any {
|
||||
it.from.lowercase() == address || it.to.lowercase() == address
|
||||
}
|
||||
|
||||
if (isInvolved) {
|
||||
// 该地址参与了交易,调用回调
|
||||
try {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
} catch (e: Exception) {
|
||||
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止连接
|
||||
*/
|
||||
fun stop() {
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
|
||||
// 关闭 WebSocket 连接
|
||||
webSocket?.close(1000, "停止监听")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
|
||||
// 清空订阅信息
|
||||
subscriptions.clear()
|
||||
requestIdToSubscriptionId.clear()
|
||||
rpcSubscriptionIdToSubscriptionId.clear()
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 服务启动时不自动连接,等待有订阅时再连接
|
||||
logger.info("统一链上 WebSocket 服务已初始化")
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -100,8 +100,9 @@ class OrderSigningService {
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
} else {
|
||||
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
|
||||
// makerAmount 是 shares 数量,最多 4 位小数
|
||||
// takerAmount 是 USDC 金额,需要使用原始价格计算(与SDK保持一致)
|
||||
// 根据 Polymarket API 要求:
|
||||
// - makerAmount (shares) 最多 2 位小数
|
||||
// - takerAmount (USDC) 最多 4 位小数
|
||||
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
|
||||
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
|
||||
@@ -109,16 +110,15 @@ class OrderSigningService {
|
||||
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
|
||||
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
|
||||
|
||||
// 确保 makerAmount 精度(shares,最多 4 位小数)
|
||||
val finalMakerAmt = roundDown(rawMakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
|
||||
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// takerAmount 不进行舍入,直接使用精确计算结果转换为 wei
|
||||
// parseUnits 会将 BigDecimal 转换为 wei(6 位小数),自动处理精度
|
||||
// 使用原始价格计算可以确保与SDK的结果一致
|
||||
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
|
||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 转换为 wei(6 位小数)
|
||||
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
}
|
||||
|
||||
+579
-219
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -298,8 +298,10 @@ class CopyTradingStatisticsService(
|
||||
}
|
||||
|
||||
// 卖出统计
|
||||
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
|
||||
// 因为每个明细都记录了准确的匹配数量和卖出价格
|
||||
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
|
||||
val totalSellAmount = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellOrders = sellRecords.size.toLong()
|
||||
|
||||
// 持仓统计
|
||||
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.repository.*
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.context.i18n.LocaleContextHolder
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 订单状态更新服务
|
||||
* 定时轮询更新卖出订单的实际成交价,并更新买入订单的实际数据并发送通知
|
||||
*/
|
||||
@Service
|
||||
class OrderStatusUpdateService(
|
||||
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||
|
||||
private val updateScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
logger.info("订单状态更新服务已启动,将每5秒轮询一次")
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时更新订单状态
|
||||
* 每5秒执行一次
|
||||
*/
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun updateOrderStatus() {
|
||||
updateScope.launch {
|
||||
try {
|
||||
// 1. 清理已删除账户的订单
|
||||
cleanupDeletedAccountOrders()
|
||||
|
||||
// 2. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
|
||||
updatePendingSellOrderPrices()
|
||||
|
||||
// 3. 更新买入订单的实际数据并发送通知
|
||||
updatePendingBuyOrders()
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单状态更新异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证订单ID格式
|
||||
* 订单ID必须以 0x 开头,且是有效的 16 进制字符串
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @return 如果格式有效返回 true,否则返回 false
|
||||
*/
|
||||
private fun isValidOrderId(orderId: String): Boolean {
|
||||
if (!orderId.startsWith("0x", ignoreCase = true)) {
|
||||
return false
|
||||
}
|
||||
// 验证是否为有效的 16 进制字符串(去除 0x 前缀后)
|
||||
val hexPart = orderId.substring(2)
|
||||
if (hexPart.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
// 检查是否只包含 0-9, a-f, A-F
|
||||
return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已删除账户的订单
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun cleanupDeletedAccountOrders() {
|
||||
try {
|
||||
// 查询所有卖出记录
|
||||
val allRecords = sellMatchRecordRepository.findAll()
|
||||
|
||||
// 查询所有有效的账户ID
|
||||
val validAccountIds = accountRepository.findAll().mapNotNull { it.id }.toSet()
|
||||
|
||||
// 查询所有有效的跟单关系
|
||||
val validCopyTradingIds = copyTradingRepository.findAll()
|
||||
.filter { it.accountId in validAccountIds }
|
||||
.mapNotNull { it.id }
|
||||
.toSet()
|
||||
|
||||
// 找出需要删除的记录(关联的跟单关系已不存在或账户已删除)
|
||||
val recordsToDelete = allRecords.filter { record ->
|
||||
val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
copyTrading == null || copyTrading.accountId !in validAccountIds
|
||||
}
|
||||
|
||||
if (recordsToDelete.isNotEmpty()) {
|
||||
logger.info("清理已删除账户的订单: ${recordsToDelete.size} 条记录")
|
||||
|
||||
// 删除匹配明细
|
||||
for (record in recordsToDelete) {
|
||||
val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!)
|
||||
sellMatchDetailRepository.deleteAll(details)
|
||||
}
|
||||
|
||||
// 删除卖出记录
|
||||
sellMatchRecordRepository.deleteAll(recordsToDelete)
|
||||
|
||||
logger.info("已清理 ${recordsToDelete.size} 条已删除账户的订单记录")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理已删除账户订单异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新待更新的卖出订单价格
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingSellOrderPrices() {
|
||||
try {
|
||||
// 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理)
|
||||
val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse()
|
||||
|
||||
if (pendingRecords.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${pendingRecords.size} 条待更新价格的卖出订单")
|
||||
|
||||
for (record in pendingRecords) {
|
||||
try {
|
||||
// 获取跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
if (copyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${record.copyTradingId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
val account = accountRepository.findById(copyTrading.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn("账户不存在,跳过更新: accountId=${copyTrading.accountId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查账户是否配置了 API 凭证
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建带认证的 CLOB API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
|
||||
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
|
||||
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
|
||||
|
||||
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则不发送通知
|
||||
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
|
||||
|
||||
if (!isAutoOrder) {
|
||||
// 非自动订单,发送通知(使用临时数据)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
useTemporaryData = true,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
} else {
|
||||
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
|
||||
}
|
||||
|
||||
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则跳过发送通知
|
||||
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
|
||||
|
||||
if (isAutoOrder) {
|
||||
logger.debug("自动生成的订单,跳过发送通知并直接标记为已处理: orderId=${record.sellOrderId}")
|
||||
// 直接标记为已处理,不发送通知
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已处理
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
continue
|
||||
}
|
||||
|
||||
// 查询订单详情,获取实际成交价
|
||||
val actualSellPrice = trackingService.getActualExecutionPrice(
|
||||
orderId = record.sellOrderId,
|
||||
clobApi = clobApi,
|
||||
fallbackPrice = record.sellPrice
|
||||
)
|
||||
|
||||
// 如果价格已更新(与当前价格不同),更新数据库
|
||||
if (actualSellPrice != record.sellPrice) {
|
||||
// 重新计算盈亏
|
||||
val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!)
|
||||
var totalRealizedPnl = BigDecimal.ZERO
|
||||
|
||||
for (detail in details) {
|
||||
val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity)
|
||||
|
||||
// 更新明细的卖出价格和盈亏
|
||||
// 注意:SellMatchDetail 的字段都是 val,需要创建新对象
|
||||
val updatedDetail = SellMatchDetail(
|
||||
id = detail.id,
|
||||
matchRecordId = detail.matchRecordId,
|
||||
trackingId = detail.trackingId,
|
||||
buyOrderId = detail.buyOrderId,
|
||||
matchedQuantity = detail.matchedQuantity,
|
||||
buyPrice = detail.buyPrice,
|
||||
sellPrice = actualSellPrice, // 更新卖出价格
|
||||
realizedPnl = updatedRealizedPnl, // 更新盈亏
|
||||
createdAt = detail.createdAt
|
||||
)
|
||||
sellMatchDetailRepository.save(updatedDetail)
|
||||
|
||||
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
|
||||
}
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
// 更新卖出记录
|
||||
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = actualSellPrice, // 更新卖出价格
|
||||
totalRealizedPnl = totalRealizedPnl, // 更新总盈亏
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
|
||||
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
} else {
|
||||
// 价格相同,但已经查询过,发送通知并标记为已处理
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新待更新卖出订单价格异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新待发送通知的买入订单
|
||||
* 查询订单详情获取实际价格和数量,然后发送通知并更新数据库
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingBuyOrders() {
|
||||
try {
|
||||
// 查询所有未发送通知的买入订单
|
||||
val pendingOrders = copyOrderTrackingRepository.findByNotificationSentFalse()
|
||||
|
||||
if (pendingOrders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${pendingOrders.size} 条待发送通知的买入订单")
|
||||
|
||||
for (order in pendingOrders) {
|
||||
try {
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(order.buyOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
|
||||
// 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(order.copyTradingId).orElse(null)
|
||||
if (copyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${order.copyTradingId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
val account = accountRepository.findById(order.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn("账户不存在,跳过更新: accountId=${order.accountId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查账户是否配置了 API 凭证
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建带认证的 CLOB API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 查询订单详情
|
||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
|
||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}")
|
||||
continue
|
||||
}
|
||||
|
||||
val orderDetail = orderResponse.body()!!
|
||||
|
||||
// 获取实际价格和数量
|
||||
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
|
||||
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
|
||||
val actualOutcome = orderDetail.outcome
|
||||
|
||||
// 更新订单数据(如果实际数据与临时数据不同)
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
|
||||
// 创建更新后的订单对象
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = actualSize, // 使用实际数量
|
||||
price = actualPrice, // 使用实际价格
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// 保存更新后的订单
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
|
||||
if (needUpdate) {
|
||||
logger.info("更新买入订单数据成功: orderId=${order.buyOrderId}, 原价格=${order.price}, 新价格=$actualPrice, 原数量=${order.quantity}, 新数量=$actualSize")
|
||||
} else {
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新待发送通知买入订单异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送买入订单通知
|
||||
*/
|
||||
private suspend fun sendBuyOrderNotification(
|
||||
order: CopyOrderTracking,
|
||||
useTemporaryData: Boolean = false,
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取跟单关系和账户信息(如果未提供)
|
||||
val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(order.copyTradingId).orElse(null)
|
||||
if (finalCopyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${order.copyTradingId}")
|
||||
return
|
||||
}
|
||||
|
||||
val finalAccount = account ?: accountRepository.findById(order.accountId).orElse(null)
|
||||
if (finalAccount == null) {
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: order.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(order.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = finalCopyTrading.configName
|
||||
|
||||
// 获取当前语言设置
|
||||
val locale = try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 创建 CLOB API 客户端(如果未提供)
|
||||
val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
finalAccount.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
finalAccount.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = order.buyOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
walletAddress = finalAccount.walletAddress,
|
||||
clobApi = finalClobApi,
|
||||
apiKey = finalAccount.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送买入订单通知失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送卖出订单通知
|
||||
*/
|
||||
private suspend fun sendSellOrderNotification(
|
||||
record: SellMatchRecord,
|
||||
useTemporaryData: Boolean = false,
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取跟单关系和账户信息(如果未提供)
|
||||
val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
if (finalCopyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${record.copyTradingId}")
|
||||
return
|
||||
}
|
||||
|
||||
val finalAccount = account ?: accountRepository.findById(finalCopyTrading.accountId).orElse(null)
|
||||
if (finalAccount == null) {
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: record.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = finalCopyTrading.configName
|
||||
|
||||
// 获取当前语言设置
|
||||
val locale = try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 创建 CLOB API 客户端(如果未提供)
|
||||
val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
finalAccount.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
finalAccount.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = record.sellOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = record.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "SELL",
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
walletAddress = finalAccount.walletAddress,
|
||||
clobApi = finalClobApi,
|
||||
apiKey = finalAccount.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送卖出订单通知失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -28,12 +28,11 @@ class ApiHealthCheckService(
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private val polymarketWsUrl: String,
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String
|
||||
private val builderRelayerUrl: String,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
@@ -186,19 +185,12 @@ class ApiHealthCheckService(
|
||||
|
||||
/**
|
||||
* 检查 Polygon RPC
|
||||
* 使用动态获取的可用节点(RpcNodeService 总是返回一个有效的 URL,包括默认节点)
|
||||
*/
|
||||
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Polygon RPC",
|
||||
url = "未配置",
|
||||
status = "skipped",
|
||||
message = "未配置 Polygon RPC URL"
|
||||
)
|
||||
}
|
||||
|
||||
val url = polygonRpcUrl
|
||||
checkJsonRpcApi("Polygon RPC", url)
|
||||
// 使用 RpcNodeService 获取可用节点(总是返回有效值,包括默认节点)
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-21
@@ -24,12 +24,11 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class RelayClientService(
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val systemConfigService: SystemConfigService
|
||||
private val systemConfigService: SystemConfigService,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RelayClientService::class.java)
|
||||
@@ -43,12 +42,9 @@ class RelayClientService(
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi? by lazy {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(polygonRpcUrl)
|
||||
}
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,7 +256,7 @@ class RelayClientService(
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
apiKey = builderApiKey,
|
||||
@@ -461,13 +457,7 @@ class RelayClientService(
|
||||
safeTx: SafeTransaction
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法执行交易")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder Relayer(Gasless)"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 从私钥推导实际签名地址(交易真正的 from 地址)
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
@@ -665,7 +655,7 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空"))
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce)
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce.asString)
|
||||
return Result.success(nonce)
|
||||
}
|
||||
|
||||
@@ -689,7 +679,7 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空"))
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce)
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce.asString)
|
||||
return Result.success(nonce)
|
||||
}
|
||||
|
||||
@@ -713,7 +703,7 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空"))
|
||||
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice)
|
||||
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice.asString)
|
||||
return Result.success(gasPrice)
|
||||
}
|
||||
|
||||
@@ -784,7 +774,7 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
|
||||
return Result.success(txHash)
|
||||
return Result.success(txHash.asString)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.entity.NodeHealthStatus
|
||||
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||
import com.wrbug.polymarketbot.entity.RpcProviderType
|
||||
import com.wrbug.polymarketbot.repository.RpcNodeConfigRepository
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* RPC 节点管理服务
|
||||
* 负责管理用户配置的 Polygon RPC 节点
|
||||
*/
|
||||
@Service
|
||||
class RpcNodeService(
|
||||
private val rpcNodeConfigRepository: RpcNodeConfigRepository,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RpcNodeService::class.java)
|
||||
|
||||
companion object {
|
||||
// 默认公共节点
|
||||
private const val DEFAULT_RPC_URL = "https://polygon.publicnode.com"
|
||||
private const val DEFAULT_WS_URL = "wss://polygon.publicnode.com"
|
||||
|
||||
// 主流服务商 URL 模板
|
||||
private val PROVIDER_HTTP_TEMPLATES = mapOf(
|
||||
RpcProviderType.ALCHEMY to "https://polygon-mainnet.g.alchemy.com/v2/{apiKey}",
|
||||
RpcProviderType.INFURA to "https://polygon-mainnet.infura.io/v3/{apiKey}",
|
||||
RpcProviderType.QUICKNODE to "https://your-endpoint.quiknode.pro/{apiKey}/",
|
||||
RpcProviderType.CHAINSTACK to "https://polygon-mainnet.core.chainstack.com/{apiKey}",
|
||||
RpcProviderType.GETBLOCK to "https://go.getblock.io/{apiKey}/"
|
||||
)
|
||||
|
||||
private val PROVIDER_WS_TEMPLATES = mapOf(
|
||||
RpcProviderType.ALCHEMY to "wss://polygon-mainnet.g.alchemy.com/v2/{apiKey}",
|
||||
RpcProviderType.INFURA to "wss://polygon-mainnet.infura.io/ws/v3/{apiKey}",
|
||||
RpcProviderType.QUICKNODE to "wss://your-endpoint.quiknode.pro/{apiKey}/",
|
||||
RpcProviderType.CHAINSTACK to "wss://ws-polygon-mainnet.core.chainstack.com/{apiKey}",
|
||||
RpcProviderType.GETBLOCK to "wss://go.getblock.io/{apiKey}/"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有节点配置(不包含默认节点)
|
||||
* 默认节点作为兜底,不应该返回给前端
|
||||
*/
|
||||
fun getAllNodes(): List<RpcNodeConfig> {
|
||||
val allNodes = rpcNodeConfigRepository.findAllByOrderByPriorityAsc()
|
||||
|
||||
// 过滤掉默认节点,只返回用户配置的节点
|
||||
return allNodes.filterNot { isDefaultNode(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有节点配置(包含默认节点,用于内部使用)
|
||||
* 只返回启用的节点,禁用的节点会被忽略
|
||||
* 默认节点始终排在最后
|
||||
*/
|
||||
fun getAllNodesWithDefault(): List<RpcNodeConfig> {
|
||||
// 只查询启用的节点
|
||||
val allNodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc()
|
||||
|
||||
// 分离默认节点和用户配置的节点
|
||||
val (defaultNodes, userNodes) = allNodes.partition { isDefaultNode(it) }
|
||||
|
||||
// 返回用户配置的节点,默认节点排在最后(如果存在)
|
||||
return userNodes + defaultNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是默认节点
|
||||
*/
|
||||
private fun isDefaultNode(node: RpcNodeConfig): Boolean {
|
||||
return node.httpUrl == DEFAULT_RPC_URL ||
|
||||
node.httpUrl == DEFAULT_RPC_URL.removeSuffix("/") ||
|
||||
(node.providerType == RpcProviderType.PUBLIC.name &&
|
||||
(node.httpUrl.contains("polygon.publicnode.com") ||
|
||||
node.httpUrl.contains("publicnode.com")))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个可用的节点
|
||||
* 按优先级顺序遍历所有启用的节点,找到第一个真正可用的节点
|
||||
* 如果所有节点都不可用,返回默认节点
|
||||
* @return 可用节点的配置,如果没有可用节点则返回失败
|
||||
*/
|
||||
fun getAvailableNode(): Result<RpcNodeConfig> {
|
||||
return try {
|
||||
val nodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc()
|
||||
.filterNot { isDefaultNode(it) } // 排除默认节点
|
||||
|
||||
if (nodes.isEmpty()) {
|
||||
logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
|
||||
return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
|
||||
}
|
||||
|
||||
// 优先使用最近检查状态为 HEALTHY 的节点
|
||||
val healthyNodes = nodes.filter {
|
||||
it.lastCheckStatus == NodeHealthStatus.HEALTHY.name
|
||||
}
|
||||
|
||||
// 先尝试使用健康的节点(按优先级排序)
|
||||
for (node in healthyNodes) {
|
||||
try {
|
||||
// 快速验证节点是否仍然可用(使用较短的超时时间)
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull()
|
||||
if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) {
|
||||
logger.debug("使用健康的 RPC 节点: ${node.name} (${node.httpUrl})")
|
||||
return Result.success(node)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}")
|
||||
// 继续尝试下一个节点
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有健康的节点,尝试验证所有节点(按优先级)
|
||||
for (node in nodes) {
|
||||
try {
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull()
|
||||
if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) {
|
||||
logger.info("找到可用的 RPC 节点: ${node.name} (${node.httpUrl})")
|
||||
return Result.success(node)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}")
|
||||
// 继续尝试下一个节点
|
||||
}
|
||||
}
|
||||
|
||||
// 所有节点都不可用,返回失败
|
||||
logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
|
||||
Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取可用节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
|
||||
*/
|
||||
fun getHttpUrl(): String {
|
||||
val nodeResult = getAvailableNode()
|
||||
return if (nodeResult.isSuccess) {
|
||||
nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
|
||||
} else {
|
||||
logger.warn("没有可用的用户配置节点,使用默认节点")
|
||||
DEFAULT_RPC_URL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 WebSocket URL (如果没有配置,使用默认节点)
|
||||
*/
|
||||
fun getWsUrl(): String {
|
||||
val nodeResult = getAvailableNode()
|
||||
return if (nodeResult.isSuccess) {
|
||||
nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL
|
||||
} else {
|
||||
logger.warn("没有可用的用户配置节点,使用默认 WS 节点")
|
||||
DEFAULT_WS_URL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
*/
|
||||
@Transactional
|
||||
fun addNode(request: AddRpcNodeRequest): Result<RpcNodeConfig> {
|
||||
return try {
|
||||
// 1. 验证请求
|
||||
val providerType = try {
|
||||
RpcProviderType.valueOf(request.providerType.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return Result.failure(IllegalArgumentException("不支持的服务商类型: ${request.providerType}"))
|
||||
}
|
||||
|
||||
// 2. 构建 HTTP 和 WS URL
|
||||
val (httpUrl, wsUrl) = if (providerType == RpcProviderType.CUSTOM) {
|
||||
// 自定义节点,使用用户提供的 URL
|
||||
if (request.httpUrl.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("自定义节点必须提供 HTTP URL"))
|
||||
}
|
||||
Pair(request.httpUrl, request.wsUrl)
|
||||
} else {
|
||||
// 主流服务商,使用模板生成 URL
|
||||
if (request.apiKey.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("${request.providerType} 节点必须提供 API Key"))
|
||||
}
|
||||
val httpTemplate = PROVIDER_HTTP_TEMPLATES[providerType]
|
||||
?: return Result.failure(IllegalArgumentException("未找到 ${request.providerType} 的 HTTP URL 模板"))
|
||||
val wsTemplate = PROVIDER_WS_TEMPLATES[providerType]
|
||||
Pair(
|
||||
httpTemplate.replace("{apiKey}", request.apiKey),
|
||||
wsTemplate?.replace("{apiKey}", request.apiKey)
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 校验节点可用性
|
||||
val validationResult = validateNode(httpUrl, wsUrl)
|
||||
if (validationResult.isFailure) {
|
||||
return Result.failure(validationResult.exceptionOrNull() ?: Exception("节点验证失败"))
|
||||
}
|
||||
|
||||
val checkResult = validationResult.getOrNull()!!
|
||||
|
||||
// 检查节点是否健康,如果不健康则不允许添加
|
||||
if (checkResult.status != NodeHealthStatus.HEALTHY) {
|
||||
return Result.failure(IllegalArgumentException("节点不可用: ${checkResult.message}"))
|
||||
}
|
||||
|
||||
// 4. 加密 API Key (如果有)
|
||||
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 5. 获取当前最大优先级
|
||||
val maxPriority = rpcNodeConfigRepository.findAllByOrderByPriorityAsc()
|
||||
.maxOfOrNull { it.priority } ?: 0
|
||||
|
||||
// 6. 创建节点配置
|
||||
val node = RpcNodeConfig(
|
||||
providerType = providerType.name,
|
||||
name = request.name,
|
||||
httpUrl = httpUrl,
|
||||
wsUrl = wsUrl,
|
||||
apiKey = encryptedApiKey,
|
||||
enabled = true,
|
||||
priority = maxPriority + 1, // 新节点放到最后
|
||||
lastCheckTime = checkResult.checkTime,
|
||||
lastCheckStatus = checkResult.status.name,
|
||||
responseTimeMs = checkResult.responseTimeMs
|
||||
)
|
||||
|
||||
val savedNode = rpcNodeConfigRepository.save(node)
|
||||
logger.info("成功添加 RPC 节点: ${savedNode.name} (${savedNode.httpUrl})")
|
||||
Result.success(savedNode)
|
||||
} catch (e: Exception) {
|
||||
logger.error("添加节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
* 默认节点不允许更新(作为兜底,不应该返回给前端)
|
||||
*/
|
||||
@Transactional
|
||||
fun updateNode(request: UpdateRpcNodeRequest): Result<RpcNodeConfig> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: ${request.id}"))
|
||||
|
||||
// 如果是默认节点,不允许更新
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
val updatedNode = node.copy(
|
||||
name = request.name ?: node.name,
|
||||
enabled = request.enabled ?: node.enabled,
|
||||
priority = request.priority ?: node.priority,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val savedNode = rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("成功更新 RPC 节点: ${savedNode.name}")
|
||||
Result.success(savedNode)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
* 默认节点不允许删除(作为兜底,不应该返回给前端)
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteNode(id: Long): Result<Unit> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: $id"))
|
||||
|
||||
// 如果是默认节点,不允许删除
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
|
||||
}
|
||||
|
||||
rpcNodeConfigRepository.delete(node)
|
||||
logger.info("成功删除 RPC 节点: ${node.name}")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点优先级
|
||||
* 默认节点不允许更新优先级(作为兜底,始终排在最后)
|
||||
*/
|
||||
@Transactional
|
||||
fun updatePriority(id: Long, priority: Int): Result<Unit> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: $id"))
|
||||
|
||||
// 如果是默认节点,不允许更新优先级
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许更新优先级"))
|
||||
}
|
||||
|
||||
val updatedNode = node.copy(
|
||||
priority = priority,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("成功更新节点优先级: ${node.name} -> $priority")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点优先级失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个节点健康状态
|
||||
* 默认节点不应该被检查(作为兜底,不应该返回给前端)
|
||||
*/
|
||||
@Transactional
|
||||
fun checkNodeHealth(nodeId: Long): Result<NodeCheckResult> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(nodeId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: $nodeId"))
|
||||
|
||||
// 如果是默认节点,不允许检查
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许检查"))
|
||||
}
|
||||
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrThrow()
|
||||
|
||||
// 更新节点健康状态
|
||||
val updatedNode = node.copy(
|
||||
lastCheckTime = checkResult.checkTime,
|
||||
lastCheckStatus = checkResult.status.name,
|
||||
responseTimeMs = checkResult.responseTimeMs,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("检查节点健康状态: ${node.name} -> ${checkResult.status}")
|
||||
Result.success(checkResult)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查节点健康状态失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查所有节点健康状态(不包含默认节点和禁用的节点)
|
||||
* 默认节点作为兜底,不应该返回给前端
|
||||
* 禁用的节点不应该被检查
|
||||
*/
|
||||
@Transactional
|
||||
fun checkAllNodesHealth(): Result<Map<Long, NodeCheckResult>> {
|
||||
return try {
|
||||
// 只查询启用的节点,过滤掉默认节点和禁用的节点
|
||||
val allNodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc()
|
||||
// 过滤掉默认节点,只检查用户配置的启用节点
|
||||
val nodes = allNodes.filterNot { isDefaultNode(it) }
|
||||
val results = mutableMapOf<Long, NodeCheckResult>()
|
||||
|
||||
for (node in nodes) {
|
||||
try {
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull()
|
||||
if (checkResult != null) {
|
||||
results[node.id!!] = checkResult
|
||||
|
||||
// 更新节点状态
|
||||
val updatedNode = node.copy(
|
||||
lastCheckTime = checkResult.checkTime,
|
||||
lastCheckStatus = checkResult.status.name,
|
||||
responseTimeMs = checkResult.responseTimeMs,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
rpcNodeConfigRepository.save(updatedNode)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查节点 ${node.name} 失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(results)
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量检查节点健康状态失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验节点可用性
|
||||
* 调用 eth_blockNumber 验证节点是否可用
|
||||
*/
|
||||
private fun validateNode(httpUrl: String, wsUrl: String?): Result<NodeCheckResult> {
|
||||
return try {
|
||||
logger.debug("开始验证节点: $httpUrl")
|
||||
|
||||
// 创建临时 RPC API
|
||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
||||
|
||||
// 调用 eth_blockNumber
|
||||
val startTime = System.currentTimeMillis()
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_blockNumber",
|
||||
params = emptyList()
|
||||
)
|
||||
|
||||
val response = kotlinx.coroutines.runBlocking { rpcApi.call(rpcRequest) }
|
||||
val responseTime = (System.currentTimeMillis() - startTime).toInt()
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("节点验证失败: HTTP ${response.code()}")
|
||||
return Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "HTTP 请求失败: ${response.code()}",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime
|
||||
))
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
logger.warn("节点验证失败: RPC 错误 ${rpcResponse.error.message}")
|
||||
return Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "RPC 错误: ${rpcResponse.error.message}",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime
|
||||
))
|
||||
}
|
||||
|
||||
val blockNumber = rpcResponse.result?.asString
|
||||
if (blockNumber.isNullOrBlank()) {
|
||||
return Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "区块号为空",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime
|
||||
))
|
||||
}
|
||||
|
||||
logger.info("节点验证成功: $httpUrl, 区块号: $blockNumber, 响应时间: ${responseTime}ms")
|
||||
Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.HEALTHY,
|
||||
message = "节点可用, 当前区块: $blockNumber",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime,
|
||||
blockNumber = blockNumber
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证节点失败: ${e.message}", e)
|
||||
Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "验证失败: ${e.message}",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = null
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点请求
|
||||
*/
|
||||
data class AddRpcNodeRequest(
|
||||
val providerType: String, // ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC
|
||||
val name: String,
|
||||
val apiKey: String? = null, // 主流服务商需要
|
||||
val httpUrl: String? = null, // CUSTOM 需要
|
||||
val wsUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新节点请求
|
||||
*/
|
||||
data class UpdateRpcNodeRequest(
|
||||
val id: Long,
|
||||
val name: String? = null,
|
||||
val enabled: Boolean? = null,
|
||||
val priority: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 节点检查结果
|
||||
*/
|
||||
data class NodeCheckResult(
|
||||
val status: NodeHealthStatus,
|
||||
val message: String,
|
||||
val checkTime: Long,
|
||||
val responseTimeMs: Int?,
|
||||
val blockNumber: String? = null
|
||||
)
|
||||
@@ -36,10 +36,38 @@ class SystemConfigService(
|
||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||
val autoRedeem = isAutoRedeemEnabled()
|
||||
|
||||
// 获取完整的 API Key(用于前端展示)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val builderSecretDisplay = builderSecret?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return SystemConfigDto(
|
||||
builderApiKeyConfigured = builderApiKey != null,
|
||||
builderSecretConfigured = builderSecret != null,
|
||||
builderPassphraseConfigured = builderPassphrase != null,
|
||||
builderApiKeyDisplay = builderApiKeyDisplay,
|
||||
builderSecretDisplay = builderSecretDisplay,
|
||||
builderPassphraseDisplay = builderPassphraseDisplay,
|
||||
autoRedeemEnabled = autoRedeem
|
||||
)
|
||||
}
|
||||
|
||||
+112
-73
@@ -65,6 +65,9 @@ class TelegramNotificationService(
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
price: String? = null, // 订单价格(可选,如果提供则直接使用)
|
||||
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
||||
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
@@ -84,35 +87,42 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 尝试从订单详情获取实际价格和数量
|
||||
var actualPrice: String? = null
|
||||
var actualSize: String? = null
|
||||
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
|
||||
var actualPrice: String? = price
|
||||
var actualSize: String? = size
|
||||
var actualSide: String = side
|
||||
var actualOutcome: String? = null // 市场方向(outcome)
|
||||
var actualOutcome: String? = outcome
|
||||
|
||||
if (orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
// 如果价格或数量未提供,尝试从订单详情获取
|
||||
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
try {
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
||||
val order = orderResponse.body()!!
|
||||
if (actualPrice == null) {
|
||||
actualPrice = order.price
|
||||
}
|
||||
if (actualSize == null) {
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
}
|
||||
actualSide = order.side // 使用订单详情中的 side
|
||||
if (actualOutcome == null) {
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询订单详情失败,使用默认值: ${e.message}", e)
|
||||
logger.warn("查询订单详情失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val price = actualPrice ?: "0"
|
||||
val size = actualSize ?: "0"
|
||||
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val finalPrice = actualPrice ?: "0"
|
||||
val finalSize = actualSize ?: "0"
|
||||
|
||||
// 计算订单金额 = price × size(USDC)
|
||||
val amount = try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = finalPrice.toSafeBigDecimal()
|
||||
val sizeDecimal = finalSize.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
@@ -126,8 +136,8 @@ class TelegramNotificationService(
|
||||
marketSlug = marketSlug,
|
||||
side = actualSide,
|
||||
outcome = actualOutcome,
|
||||
price = price,
|
||||
size = size,
|
||||
price = finalPrice,
|
||||
size = finalSize,
|
||||
amount = amount,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
@@ -296,18 +306,8 @@ class TelegramNotificationService(
|
||||
else -> filterType
|
||||
}
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
@@ -359,13 +359,17 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
// 格式化价格和数量
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """🚫 <b>$orderFiltered</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
@@ -570,6 +574,68 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化价格显示(保留最多4位小数,截断不四舍五入)
|
||||
*/
|
||||
private fun formatPrice(price: String): String {
|
||||
return try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val formatted = if (priceDecimal.scale() > 4) {
|
||||
priceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
priceDecimal.stripTrailingZeros()
|
||||
}
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) {
|
||||
price
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数量显示(保留最多2位小数,截断不四舍五入)
|
||||
*/
|
||||
private fun formatQuantity(quantity: String): String {
|
||||
return try {
|
||||
val quantityDecimal = quantity.toSafeBigDecimal()
|
||||
val formatted = if (quantityDecimal.scale() > 2) {
|
||||
quantityDecimal.setScale(2, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
quantityDecimal.stripTrailingZeros()
|
||||
}
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) {
|
||||
quantity
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建账户信息显示(格式:账户名(钱包地址))
|
||||
*/
|
||||
private fun buildAccountInfo(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
unknownAccount: String
|
||||
): String {
|
||||
return when {
|
||||
!accountName.isNullOrBlank() && !walletAddress.isNullOrBlank() -> {
|
||||
// 有账户名和钱包地址:账户名(钱包地址)
|
||||
"${accountName}(${maskAddress(walletAddress)})"
|
||||
}
|
||||
!accountName.isNullOrBlank() -> {
|
||||
// 只有账户名
|
||||
accountName
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
// 只有钱包地址
|
||||
maskAddress(walletAddress)
|
||||
}
|
||||
else -> {
|
||||
// 都没有
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单成功消息
|
||||
*/
|
||||
@@ -613,18 +679,8 @@ class TelegramNotificationService(
|
||||
else -> side
|
||||
}
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
// 构建跟单信息(如果有)
|
||||
val copyTradingInfo = mutableListOf<String>()
|
||||
@@ -694,14 +750,18 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
// 格式化价格和数量
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """✅ <b>$orderCreatedSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
|
||||
@@ -748,18 +808,8 @@ class TelegramNotificationService(
|
||||
else -> side
|
||||
}
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
@@ -818,13 +868,17 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
// 格式化价格和数量
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """❌ <b>$orderCreatedFailed</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
@@ -889,18 +943,8 @@ class TelegramNotificationService(
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
@@ -923,12 +967,7 @@ class TelegramNotificationService(
|
||||
|
||||
// 构建仓位列表
|
||||
val positionsText = positions.joinToString("\n") { position ->
|
||||
val quantityDisplay = try {
|
||||
val quantityDecimal = position.quantity.toSafeBigDecimal()
|
||||
quantityDecimal.stripTrailingZeros().toPlainString()
|
||||
} catch (e: Exception) {
|
||||
position.quantity
|
||||
}
|
||||
val quantityDisplay = formatQuantity(position.quantity)
|
||||
val valueDisplay = try {
|
||||
val valueDecimal = position.value.toSafeBigDecimal()
|
||||
val formatted = if (valueDecimal.scale() > 4) {
|
||||
|
||||
@@ -77,6 +77,67 @@ object EthereumUtils {
|
||||
return BigInteger(cleanHex, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ABI 编码的响应中解析 uint256 数组
|
||||
* ABI 编码格式:
|
||||
* - offset (32 bytes): 数组数据的位置偏移量
|
||||
* - length (32 bytes): 数组长度
|
||||
* - data: 每个元素 32 字节
|
||||
* @param hexResult 十六进制结果(完整的 ABI 编码响应)
|
||||
* @param offset 数组数据的偏移位置(字节数,从 offset 位置开始读取)
|
||||
* @return BigInteger 数组
|
||||
*/
|
||||
fun decodeUint256Array(hexResult: String, offset: Int = 0): List<BigInteger> {
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
if (cleanHex.length < (offset + 1) * 64) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// 从 offset 位置开始读取
|
||||
val startPos = offset * 64 // 每个 uint256 是 64 个十六进制字符
|
||||
val lengthHex = cleanHex.substring(startPos, startPos + 64)
|
||||
val length = BigInteger(lengthHex, 16).toInt()
|
||||
|
||||
if (length <= 0 || length > 100) { // 防止异常数据
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val result = mutableListOf<BigInteger>()
|
||||
for (i in 0 until length) {
|
||||
val elementStart = startPos + 64 + (i * 64) // 跳过长度字段
|
||||
if (elementStart + 64 > cleanHex.length) {
|
||||
break
|
||||
}
|
||||
val elementHex = cleanHex.substring(elementStart, elementStart + 64)
|
||||
result.add(BigInteger(elementHex, 16))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ABI 编码的元组响应中解析数据
|
||||
* 用于解析 getCondition 返回的 (uint256 payoutDenominator, uint256[] payouts)
|
||||
* @param hexResult 十六进制结果
|
||||
* @return Pair<payoutDenominator, payouts>
|
||||
*/
|
||||
fun decodeConditionResult(hexResult: String): Pair<BigInteger, List<BigInteger>> {
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
|
||||
// 第一个 32 字节:payoutDenominator
|
||||
val payoutDenominatorHex = cleanHex.substring(0, 64)
|
||||
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
|
||||
|
||||
// 第二个 32 字节:payouts 数组的偏移量(通常是 0x40 = 64 字节)
|
||||
val offsetHex = cleanHex.substring(64, 128)
|
||||
val offset = BigInteger(offsetHex, 16).toInt() / 32 // 转换为 32 字节单位
|
||||
|
||||
// 从 offset 位置解析数组
|
||||
val payouts = decodeUint256Array(hexResult, offset)
|
||||
|
||||
return Pair(payoutDenominator, payouts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Keccak-256 哈希(Ethereum 标准)
|
||||
* 使用 BouncyCastle 库实现真正的 Keccak-256
|
||||
|
||||
@@ -8,9 +8,16 @@ import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
@@ -95,11 +102,32 @@ class RetrofitFactory(
|
||||
|
||||
/**
|
||||
* 创建 Ethereum RPC API 客户端
|
||||
* 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL
|
||||
* 如果 RPC 不可用,将抛出异常
|
||||
* @param rpcUrl RPC 节点 URL
|
||||
* @return EthereumRpcApi 客户端
|
||||
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
|
||||
*/
|
||||
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
|
||||
val okHttpClient = createClient().build()
|
||||
// 使用固定的 baseUrl(Retrofit 要求 baseUrl 必须以 / 结尾)
|
||||
val fixedBaseUrl = "https://polyrpc.polyhermes/"
|
||||
|
||||
// 确保实际的 RPC URL 以 / 结尾
|
||||
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
|
||||
rpcUrl
|
||||
} else {
|
||||
"$rpcUrl/"
|
||||
}
|
||||
|
||||
// 验证 RPC 是否可用
|
||||
validateRpcAvailability(actualRpcUrl)
|
||||
|
||||
// 创建 URL 替换拦截器
|
||||
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(urlReplaceInterceptor)
|
||||
.build()
|
||||
|
||||
// 创建 lenient 模式的 Gson
|
||||
val gson = GsonBuilder()
|
||||
@@ -107,13 +135,86 @@ class RetrofitFactory(
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(rpcUrl)
|
||||
.baseUrl(fixedBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(EthereumRpcApi::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 RPC 节点是否可用
|
||||
* 通过发送一个简单的 eth_blockNumber 请求来验证
|
||||
* @param rpcUrl RPC 节点 URL
|
||||
* @throws IllegalArgumentException 如果 RPC 不可用
|
||||
*/
|
||||
private fun validateRpcAvailability(rpcUrl: String) {
|
||||
val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
|
||||
|
||||
try {
|
||||
// 解析 URL
|
||||
val httpUrl = rpcUrl.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("无效的 RPC URL: $rpcUrl")
|
||||
|
||||
// 创建 JSON-RPC 请求体
|
||||
val jsonRpcRequest = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_blockNumber",
|
||||
"params": [],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonRpcRequest.toRequestBody(mediaType)
|
||||
|
||||
// 创建请求
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
.post(requestBody)
|
||||
.header("Content-Type", "application/json")
|
||||
.build()
|
||||
|
||||
// 创建临时客户端用于验证(使用较短的超时时间)
|
||||
val testClient = createClient()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// 发送请求
|
||||
val response = testClient.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalArgumentException("RPC 节点不可用: HTTP ${response.code} ${response.message}")
|
||||
}
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
if (responseBody.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("RPC 节点响应为空")
|
||||
}
|
||||
|
||||
// 检查响应是否包含错误
|
||||
if (responseBody.contains("\"error\"")) {
|
||||
throw IllegalArgumentException("RPC 节点返回错误: $responseBody")
|
||||
}
|
||||
|
||||
// 检查响应是否包含 result
|
||||
if (!responseBody.contains("\"result\"")) {
|
||||
throw IllegalArgumentException("RPC 节点响应格式错误: $responseBody")
|
||||
}
|
||||
|
||||
logger.debug("RPC 节点验证成功: $rpcUrl")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.error("RPC 节点验证失败: $rpcUrl - ${e.message}")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.error("RPC 节点验证失败: $rpcUrl - ${e.message}", e)
|
||||
throw IllegalArgumentException("RPC 节点不可用: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Polymarket Gamma API 客户端
|
||||
* Gamma API 是公开 API,不需要认证
|
||||
@@ -239,6 +340,39 @@ class RetrofitFactory(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC URL 替换拦截器
|
||||
* 用于将固定的 baseUrl 替换为实际的 RPC URL
|
||||
*/
|
||||
class RpcUrlReplaceInterceptor(
|
||||
private val fixedBaseUrl: String,
|
||||
private val actualRpcUrl: String
|
||||
) : Interceptor {
|
||||
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val originalUrl = originalRequest.url
|
||||
|
||||
// 将固定 baseUrl 替换为实际的 RPC URL
|
||||
val originalUrlString = originalUrl.toString()
|
||||
val newUrlString = originalUrlString.replace(fixedBaseUrl, actualRpcUrl)
|
||||
|
||||
// 使用 HttpUrl 解析新 URL,确保格式正确
|
||||
val newUrl = newUrlString.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
|
||||
|
||||
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应日志拦截器
|
||||
* 用于记录 API 响应的原始内容,帮助调试 JSON 解析错误
|
||||
|
||||
@@ -40,23 +40,12 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
|
||||
# Polygon RPC 配置(用于查询链上余额)
|
||||
# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
|
||||
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# Builder Relayer 配置(用于 Gasless 交易)
|
||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
||||
|
||||
# 仓位推送配置
|
||||
# 轮询间隔(毫秒),默认3秒
|
||||
position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000}
|
||||
# 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接
|
||||
position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000}
|
||||
|
||||
# 跟单轮询配置
|
||||
# 轮询间隔(毫秒),默认2秒
|
||||
copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ============================================
|
||||
-- V11: 添加最大仓位配置字段
|
||||
-- 在 copy_trading 表中添加最大仓位金额和最大仓位数量配置
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE copy_trading
|
||||
ADD COLUMN max_position_value DECIMAL(20, 8) NULL COMMENT '最大仓位金额(USDC),NULL表示不启用' AFTER max_price,
|
||||
ADD COLUMN max_position_count INT NULL COMMENT '最大仓位数量,NULL表示不启用' AFTER max_position_value;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 创建 RPC 节点配置表
|
||||
CREATE TABLE rpc_node_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
|
||||
provider_type VARCHAR(50) NOT NULL COMMENT '服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC',
|
||||
name VARCHAR(100) NOT NULL COMMENT '节点名称',
|
||||
http_url VARCHAR(500) NOT NULL COMMENT 'HTTP RPC URL',
|
||||
ws_url VARCHAR(500) COMMENT 'WebSocket URL (可选)',
|
||||
api_key VARCHAR(200) COMMENT 'API Key (加密存储)',
|
||||
enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
priority INT DEFAULT 0 COMMENT '优先级(数字越小优先级越高)',
|
||||
last_check_time BIGINT COMMENT '最后检查时间(毫秒时间戳)',
|
||||
last_check_status VARCHAR(20) COMMENT '最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN',
|
||||
response_time_ms INT COMMENT '最后一次响应时间(毫秒)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_enabled_priority (enabled, priority),
|
||||
INDEX idx_last_check_status (last_check_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Polygon RPC 节点配置表';
|
||||
|
||||
-- 插入默认公共节点 (PublicNode)
|
||||
INSERT INTO rpc_node_config (
|
||||
provider_type,
|
||||
name,
|
||||
http_url,
|
||||
ws_url,
|
||||
enabled,
|
||||
priority,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_check_status
|
||||
) VALUES (
|
||||
'PUBLIC',
|
||||
'PublicNode (Default)',
|
||||
'https://polygon.publicnode.com',
|
||||
'wss://polygon.publicnode.com',
|
||||
TRUE,
|
||||
999,
|
||||
UNIX_TIMESTAMP() * 1000,
|
||||
UNIX_TIMESTAMP() * 1000,
|
||||
'UNKNOWN'
|
||||
);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- 添加 price_updated 字段到 sell_match_record 表
|
||||
-- 用于标记卖出价格是否已从订单详情中更新
|
||||
ALTER TABLE sell_match_record
|
||||
ADD COLUMN price_updated BOOLEAN DEFAULT FALSE COMMENT '价格是否已更新(从订单详情获取实际成交价)';
|
||||
|
||||
-- 为已存在的记录设置默认值
|
||||
UPDATE sell_match_record SET price_updated = TRUE WHERE price_updated IS NULL;
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- 添加 notification_sent 字段到 copy_order_tracking 表
|
||||
-- 用于标记买入订单是否已发送通知
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD COLUMN notification_sent BOOLEAN DEFAULT FALSE COMMENT '是否已发送通知(从订单详情获取实际数据后发送)';
|
||||
|
||||
-- 为已存在的记录设置默认值(已存在的订单视为已发送通知)
|
||||
UPDATE copy_order_tracking SET notification_sent = TRUE WHERE notification_sent IS NULL;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- 添加 Leader 买入数量字段,用于固定金额模式计算卖出比例
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD COLUMN leader_buy_quantity DECIMAL(20, 8) DEFAULT NULL COMMENT 'Leader 买入数量(用于固定金额模式计算卖出比例)';
|
||||
|
||||
-- 对于已有数据,如果无法从 API 查询,设置为 NULL(不影响现有功能)
|
||||
-- 新创建的记录会在创建时自动填充此字段
|
||||
|
||||
@@ -71,9 +71,6 @@ SERVER_PORT=80
|
||||
# MySQL 端口(可选,用于外部连接,默认 3307 避免与本地 MySQL 冲突)
|
||||
MYSQL_PORT=3307
|
||||
|
||||
# Polygon RPC
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
|
||||
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
|
||||
|
||||
@@ -38,12 +38,6 @@ SERVER_PORT=80
|
||||
# Optional, for external connections, default 3307 to avoid conflicts with local MySQL
|
||||
MYSQL_PORT=3307
|
||||
|
||||
# ============================================
|
||||
# Polygon RPC
|
||||
# ============================================
|
||||
# Polygon 网络 RPC 地址 / Polygon network RPC address
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
|
||||
# ============================================
|
||||
# 安全配置 / Security Configuration
|
||||
# ============================================
|
||||
|
||||
@@ -24,7 +24,6 @@ services:
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
||||
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
||||
# 请在 .env 文件中设置,或通过环境变量传入
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
|
||||
@@ -22,7 +22,6 @@ services:
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
||||
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
||||
# 请在 .env 文件中设置,或通过环境变量传入
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
|
||||
+33
-4
@@ -104,6 +104,38 @@ export IMAGE_TAG=v1.0.0
|
||||
# In docker-compose.prod.yml use: image: wrbug/polyhermes:${IMAGE_TAG:-latest}
|
||||
```
|
||||
|
||||
**Update Docker Version**:
|
||||
|
||||
When a new version is released, you can update using the following steps:
|
||||
|
||||
```bash
|
||||
# 1. Stop currently running containers
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. Pull the latest version image (or specific version)
|
||||
# Update to latest version
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# Or update to specific version (e.g., v1.0.1)
|
||||
docker pull wrbug/polyhermes:v1.0.1
|
||||
|
||||
# 3. If using a specific version, modify the image tag in docker-compose.prod.yml
|
||||
# Edit docker-compose.prod.yml, change image to:
|
||||
# image: wrbug/polyhermes:v1.0.1
|
||||
|
||||
# 4. Restart services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 5. Check logs to confirm services started normally
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- ⚠️ It is recommended to backup the database before updating (if using MySQL in Docker Compose)
|
||||
- ⚠️ Service will be briefly interrupted during update, recommend updating during off-peak hours
|
||||
- ✅ Using `docker-compose pull` can automatically pull the latest image and update (if using `latest` tag)
|
||||
- ✅ View available versions: Visit [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) or [GitHub Releases](https://github.com/WrBug/PolyHermes/releases)
|
||||
|
||||
2. **Local Build Deployment (Development Environment)**
|
||||
|
||||
Suitable for development environments or scenarios requiring custom builds.
|
||||
@@ -131,7 +163,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=80
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -361,7 +392,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=8000
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -522,7 +552,6 @@ serve -s dist -l 3000
|
||||
| `DB_USERNAME` | Database username | `root` | Yes (Production) |
|
||||
| `DB_PASSWORD` | Database password | - | Yes (Production) |
|
||||
| `SERVER_PORT` | Server port | `8000` | No |
|
||||
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` | No |
|
||||
| `JWT_SECRET` | JWT secret key | - | Yes (Production) |
|
||||
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | Yes (Production) |
|
||||
|
||||
@@ -648,5 +677,5 @@ Vite uses `.env.production` file to inject environment variables during build. T
|
||||
|
||||
## Technical Support
|
||||
|
||||
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/quant_tr).
|
||||
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/polyhermes).
|
||||
|
||||
|
||||
@@ -100,9 +100,6 @@ spring.datasource.password=${DB_PASSWORD:password}
|
||||
# Server port
|
||||
server.port=${SERVER_PORT:8000}
|
||||
|
||||
# Polygon RPC
|
||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# JWT secret
|
||||
jwt.secret=${JWT_SECRET:change-me-in-production}
|
||||
|
||||
|
||||
+37
-4
@@ -102,6 +102,42 @@ export IMAGE_TAG=v1.0.0
|
||||
# 在 docker-compose.prod.yml 中使用: image: wrbug/polyhermes:${IMAGE_TAG:-latest}
|
||||
```
|
||||
|
||||
**更新 Docker 版本**:
|
||||
|
||||
当有新版本发布时,可以通过以下步骤更新:
|
||||
|
||||
```bash
|
||||
# 1. 停止当前运行的容器
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. 拉取最新版本的镜像(或指定版本)
|
||||
# 更新到最新版本
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 或更新到特定版本(例如 v1.0.1)
|
||||
docker pull wrbug/polyhermes:v1.0.1
|
||||
|
||||
# 3. 如果使用特定版本,需要修改 docker-compose.prod.yml 中的镜像标签
|
||||
# 编辑 docker-compose.prod.yml,将 image 改为:
|
||||
# image: wrbug/polyhermes:v1.0.1
|
||||
|
||||
# 4. 重新启动服务
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 5. 查看日志确认服务正常启动
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ **备份数据库(强烈推荐)**:
|
||||
- 备份不是必须的,但强烈推荐,特别是生产环境
|
||||
- Docker 更新不会删除数据(数据存储在独立的数据卷中)
|
||||
- 但数据库结构可能会变更,如果迁移失败,备份可以帮助恢复
|
||||
- 备份命令:`docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql`
|
||||
- ⚠️ 更新过程中服务会短暂中断,建议在低峰期进行
|
||||
- ✅ 使用 `docker-compose pull` 可以自动拉取最新镜像并更新(如果使用 `latest` 标签)
|
||||
- ✅ 查看可用版本:访问 [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) 或 [GitHub Releases](https://github.com/WrBug/PolyHermes/releases)
|
||||
|
||||
2. **本地构建部署(开发环境)**
|
||||
|
||||
适用于开发环境或需要自定义构建的场景。
|
||||
@@ -129,7 +165,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=80
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -361,7 +396,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=8000
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -522,7 +556,6 @@ serve -s dist -l 3000
|
||||
| `DB_USERNAME` | 数据库用户名 | `root` | 是(生产) |
|
||||
| `DB_PASSWORD` | 数据库密码 | - | 是(生产) |
|
||||
| `SERVER_PORT` | 服务器端口 | `8000` | 否 |
|
||||
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` | 否 |
|
||||
| `JWT_SECRET` | JWT 密钥 | - | 是(生产) |
|
||||
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | 是(生产) |
|
||||
|
||||
@@ -648,5 +681,5 @@ Vite 使用 `.env.production` 文件在构建时注入环境变量。构建脚
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请提交 Issue 到 [GitHub](https://github.com/WrBug/PolyHermes) 或联系 [Twitter](https://x.com/quant_tr)。
|
||||
如有问题,请提交 Issue 到 [GitHub](https://github.com/WrBug/PolyHermes) 或联系 [Twitter](https://x.com/polyhermes)。
|
||||
|
||||
|
||||
@@ -93,9 +93,6 @@ spring.datasource.password=${DB_PASSWORD:password}
|
||||
# 服务器端口
|
||||
server.port=${SERVER_PORT:8000}
|
||||
|
||||
# Polygon RPC
|
||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# JWT 密钥
|
||||
jwt.secret=${JWT_SECRET:change-me-in-production}
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# 跟单买卖逻辑简易文档
|
||||
|
||||
## 一、跟单信号检测
|
||||
|
||||
### 1.1 监控方式
|
||||
|
||||
系统使用**轮询方式**监控 Leader 的交易活动:
|
||||
|
||||
- **数据源**:Polymarket Data API 的 `/activity` 接口
|
||||
- **轮询间隔**:默认 2 秒(可配置)
|
||||
- **查询参数**:
|
||||
- `user`: Leader 钱包地址
|
||||
- `type`: `["TRADE"]`(只查询交易类型)
|
||||
- `limit`: 100(每次最多查询 100 条)
|
||||
- `sortBy`: `TIMESTAMP`
|
||||
- `sortDirection`: `DESC`(按时间戳降序,最新的在前)
|
||||
|
||||
### 1.2 增量检测机制
|
||||
|
||||
通过 **diff 算法**检测新增交易:
|
||||
|
||||
1. **首次轮询**:
|
||||
- 查询最近 100 条交易
|
||||
- 缓存所有交易 ID(不处理)
|
||||
- 标记首次轮询完成
|
||||
|
||||
2. **后续轮询**:
|
||||
- 查询最近 100 条交易
|
||||
- 与缓存的交易 ID 集合进行 diff
|
||||
- 找出新增的交易 ID
|
||||
- 处理新增交易
|
||||
- 更新缓存(添加新增的交易 ID)
|
||||
|
||||
3. **去重机制**:
|
||||
- 使用 `leaderId + tradeId` 作为唯一标识
|
||||
- 在 `processed_trade` 表中记录已处理的交易
|
||||
- 避免重复处理同一笔交易
|
||||
|
||||
### 1.3 交易数据转换
|
||||
|
||||
从 `UserActivityResponse` 转换为 `TradeResponse`:
|
||||
|
||||
```kotlin
|
||||
TradeResponse(
|
||||
id = activity.transactionHash, // 交易ID(用于去重)
|
||||
market = activity.conditionId, // 市场ID
|
||||
side = activity.side, // "BUY" 或 "SELL"
|
||||
price = activity.price, // 交易价格
|
||||
size = activity.size, // 交易数量
|
||||
timestamp = activity.timestamp, // 时间戳(秒)
|
||||
user = activity.proxyWallet, // 用户钱包地址
|
||||
outcomeIndex = activity.outcomeIndex, // 结果索引(0=第一个outcome,1=第二个outcome)
|
||||
outcome = activity.outcome // 结果名称
|
||||
)
|
||||
```
|
||||
|
||||
### 1.4 信号触发流程
|
||||
|
||||
```
|
||||
轮询任务启动
|
||||
↓
|
||||
定期轮询所有 Leader(每 2 秒)
|
||||
↓
|
||||
查询 Leader 活动(/activity 接口)
|
||||
↓
|
||||
转换为 TradeResponse
|
||||
↓
|
||||
diff 检测新增交易
|
||||
↓
|
||||
调用 processTrade() 处理交易
|
||||
↓
|
||||
根据 side 字段判断:
|
||||
- "BUY" → processBuyTrade()
|
||||
- "SELL" → processSellTrade()
|
||||
```
|
||||
|
||||
## 二、订单构建
|
||||
|
||||
### 2.1 买入订单构建流程
|
||||
|
||||
#### 2.1.1 前置检查
|
||||
|
||||
1. **查找跟单关系**:
|
||||
- 查询所有启用且支持该 Leader 的跟单配置
|
||||
- 验证账户 API 凭证是否配置
|
||||
- 验证账户是否启用
|
||||
|
||||
2. **获取 Token ID**:
|
||||
- 使用 `outcomeIndex` 和 `market` 获取 tokenId
|
||||
- 支持多元市场(不限于 YES/NO)
|
||||
|
||||
3. **计算买入数量**:
|
||||
- **RATIO 模式**:`买入数量 = Leader 数量 × 跟单比例`
|
||||
- **FIXED 模式**:`买入数量 = 固定金额 / 买入价格`
|
||||
|
||||
4. **过滤条件检查**:
|
||||
- 价格区间检查
|
||||
- 仓位限制检查
|
||||
- 市场分类检查
|
||||
- 订单簿检查(获取最佳卖单价格)
|
||||
|
||||
5. **价格调整**:
|
||||
- 应用价格容忍度(`priceTolerance`)
|
||||
- 买入价格 = Leader 价格 × (1 + 容忍度)
|
||||
- 确保调整后的价格不低于最佳卖单价格
|
||||
|
||||
#### 2.1.2 订单签名
|
||||
|
||||
使用 `OrderSigningService.createAndSignOrder()` 创建并签名订单:
|
||||
|
||||
1. **计算订单金额**:
|
||||
- **BUY 订单**:
|
||||
- `makerAmount = price × size`(USDC 金额,最多 2 位小数)
|
||||
- `takerAmount = size`(shares 数量,最多 4 位小数)
|
||||
- 转换为 wei(6 位小数)
|
||||
|
||||
2. **生成订单参数**:
|
||||
- `salt`: 时间戳(毫秒)
|
||||
- `maker`: 代理钱包地址(proxyAddress)
|
||||
- `signer`: 从私钥推导的签名地址
|
||||
- `taker`: 零地址(0x0000...)
|
||||
- `tokenId`: 从 outcomeIndex 获取
|
||||
- `makerAmount`: 计算出的 maker 金额(wei)
|
||||
- `takerAmount`: 计算出的 taker 金额(wei)
|
||||
- `expiration`: "0"(永不过期)
|
||||
- `nonce`: "0"
|
||||
- `feeRateBps`: "0"
|
||||
- `side`: "BUY"
|
||||
- `signatureType`: 2(Browser Wallet)
|
||||
|
||||
3. **EIP-712 签名**:
|
||||
- 编码域分隔符(Exchange Contract + Chain ID)
|
||||
- 编码订单消息哈希
|
||||
- 计算结构化数据哈希
|
||||
- 使用私钥签名(r + s + v)
|
||||
|
||||
#### 2.1.3 创建订单请求
|
||||
|
||||
构建 `NewOrderRequest`:
|
||||
|
||||
```kotlin
|
||||
NewOrderRequest(
|
||||
order = signedOrder, // 签名的订单对象
|
||||
owner = account.apiKey, // API Key
|
||||
orderType = "FAK", // Fill-And-Kill(允许部分成交,未成交部分立即取消)
|
||||
deferExec = false // 立即执行
|
||||
)
|
||||
```
|
||||
|
||||
#### 2.1.4 提交订单
|
||||
|
||||
1. **创建 CLOB API 客户端**(带认证):
|
||||
- 使用账户的 API Key、Secret、Passphrase
|
||||
- 解密 API 凭证
|
||||
|
||||
2. **调用 API 创建订单**:
|
||||
- `POST /orders`
|
||||
- 带重试机制(最多重试 2 次)
|
||||
- 每次重试都重新生成 salt 并重新签名
|
||||
|
||||
3. **记录订单跟踪**:
|
||||
- 保存到 `copy_order_tracking` 表
|
||||
- 记录买入订单 ID、数量、价格等信息
|
||||
- 状态:`filled`
|
||||
|
||||
### 2.2 卖出订单构建流程
|
||||
|
||||
#### 2.2.1 前置检查
|
||||
|
||||
1. **查找跟单关系**:
|
||||
- 查询所有启用且支持该 Leader 的跟单配置
|
||||
- 验证是否支持卖出(`supportSell = true`)
|
||||
|
||||
2. **计算需要匹配的数量**:
|
||||
- `需要匹配数量 = Leader 卖出数量 × 跟单比例`
|
||||
|
||||
3. **查找未匹配的买入订单**:
|
||||
- 使用 `outcomeIndex` 匹配(支持多元市场)
|
||||
- 按 FIFO 顺序(先进先出)
|
||||
- 查询 `copy_order_tracking` 表中未匹配的订单
|
||||
|
||||
4. **计算实际可卖出数量**:
|
||||
- 按 FIFO 顺序匹配
|
||||
- 支持部分匹配(一个买入订单可以被多次卖出匹配)
|
||||
|
||||
#### 2.2.2 价格计算
|
||||
|
||||
1. **优先使用订单簿 bestBid**:
|
||||
- 查询订单簿(`getOrderbookByTokenId`)
|
||||
- 获取最佳买单价格(bestBid)
|
||||
- 使用 bestBid 作为卖出价格
|
||||
|
||||
2. **备选方案**:
|
||||
- 如果获取订单簿失败,使用 Leader 价格
|
||||
- 卖出价格 = Leader 价格 × 0.9(固定按 90% 计算)
|
||||
|
||||
#### 2.2.3 订单签名
|
||||
|
||||
与买入订单类似,但:
|
||||
- `side`: "SELL"
|
||||
- **SELL 订单金额计算**:
|
||||
- `makerAmount = size`(shares 数量,最多 4 位小数)
|
||||
- `takerAmount = price × size`(USDC 金额,使用原始价格计算)
|
||||
|
||||
#### 2.2.4 创建订单请求
|
||||
|
||||
与买入订单相同:
|
||||
- `orderType`: "FAK"
|
||||
- `deferExec`: false
|
||||
|
||||
#### 2.2.5 提交订单
|
||||
|
||||
1. **调用 API 创建卖出订单**(带重试机制)
|
||||
|
||||
2. **更新买入订单状态**:
|
||||
- 更新 `copy_order_tracking` 表中的 `remainingQuantity`
|
||||
- 如果完全匹配,状态更新为 `fully_matched`
|
||||
- 如果部分匹配,状态更新为 `partially_matched`
|
||||
|
||||
3. **记录匹配关系**:
|
||||
- 保存到 `sell_match_record` 表(卖出匹配记录)
|
||||
- 保存到 `sell_match_detail` 表(匹配明细,包含盈亏计算)
|
||||
|
||||
## 三、关键配置
|
||||
|
||||
### 3.1 跟单配置参数
|
||||
|
||||
- `copyMode`: 跟单模式("RATIO" 或 "FIXED")
|
||||
- `copyRatio`: 跟单比例(RATIO 模式)
|
||||
- `fixedAmount`: 固定金额(FIXED 模式)
|
||||
- `priceTolerance`: 价格容忍度(买入时使用)
|
||||
- `supportSell`: 是否支持卖出
|
||||
- `enabled`: 是否启用
|
||||
|
||||
### 3.2 订单类型
|
||||
|
||||
- **FAK (Fill-And-Kill)**:
|
||||
- 允许部分成交
|
||||
- 未成交部分立即取消
|
||||
- 快速响应 Leader 交易,避免订单长期挂单导致价格不匹配
|
||||
|
||||
### 3.3 重试机制
|
||||
|
||||
- **最多重试次数**:2 次(首次 + 1 次重试)
|
||||
- **重试延迟**:3 秒
|
||||
- **重试策略**:每次重试都重新生成 salt 并重新签名,确保签名唯一性
|
||||
|
||||
## 四、数据流向
|
||||
|
||||
```
|
||||
Leader 交易(链上)
|
||||
↓
|
||||
Polymarket Data API (/activity)
|
||||
↓
|
||||
轮询服务(CopyTradingPollingService)
|
||||
↓
|
||||
交易处理服务(CopyOrderTrackingService)
|
||||
↓
|
||||
订单签名服务(OrderSigningService)
|
||||
↓
|
||||
CLOB API (POST /orders)
|
||||
↓
|
||||
订单跟踪表(copy_order_tracking)
|
||||
```
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. **去重机制**:
|
||||
- 使用 `leaderId + tradeId` 作为唯一标识
|
||||
- 在 `processed_trade` 表中记录已处理的交易
|
||||
- 避免重复处理同一笔交易
|
||||
|
||||
2. **价格调整**:
|
||||
- 买入时应用价格容忍度(提高买入价格)
|
||||
- 卖出时优先使用订单簿 bestBid,失败则使用 Leader 价格的 90%
|
||||
|
||||
3. **订单簿检查**:
|
||||
- 买入前检查订单簿中是否有可匹配的卖单
|
||||
- 确保调整后的买入价格不低于最佳卖单价格
|
||||
|
||||
4. **FIFO 匹配**:
|
||||
- 卖出时按买入时间顺序匹配(先进先出)
|
||||
- 支持部分匹配
|
||||
|
||||
5. **错误处理**:
|
||||
- 订单创建失败时记录到 `failed_trade` 表
|
||||
- 支持重试机制(最多 2 次)
|
||||
- 发送失败通知(如果配置了 `pushFailedOrders`)
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
# 跟单监听策略:链上 WebSocket + 轮询并行方案
|
||||
|
||||
## 一、方案概述
|
||||
|
||||
系统**同时运行**两种监听方式,并行处理,哪个数据先返回就用哪个:
|
||||
|
||||
1. **链上 WebSocket 监听**:通过 Polygon RPC 的 `eth_subscribe` 实时监听链上交易
|
||||
2. **轮询监听**:通过 Polymarket Data API 定期轮询交易记录
|
||||
|
||||
**核心特点**:
|
||||
- 两种方式**并行运行**,不互相排斥
|
||||
- WS 断开时**不断重试连接**,不停止轮询
|
||||
- 哪个数据先返回就用哪个,确保最快响应
|
||||
- 通过去重机制确保同一笔交易只处理一次
|
||||
|
||||
## 二、关键流程图
|
||||
|
||||
```
|
||||
系统启动
|
||||
↓
|
||||
同时启动两种监听方式
|
||||
↓
|
||||
├─→ 链上 WS 监听(并行)
|
||||
│ ↓
|
||||
│ 尝试连接 WS RPC
|
||||
│ ↓
|
||||
│ 连接成功?
|
||||
│ ├─→ 是 → 订阅 Leader 钱包地址
|
||||
│ │ (eth_subscribe: USDC Transfer + ERC1155 Transfer)
|
||||
│ │ ↓
|
||||
│ │ 实时接收交易日志
|
||||
│ │ ↓
|
||||
│ │ 解析交易 receipt
|
||||
│ │ ↓
|
||||
│ │ 转换为 TradeResponse
|
||||
│ │ ↓
|
||||
│ │ 调用 processTrade()(去重检查)
|
||||
│ │ ↓
|
||||
│ │ 连接断开?
|
||||
│ │ ├─→ 是 → 等待重连延迟 → 重试连接(循环)
|
||||
│ │ └─→ 否 → 继续监听
|
||||
│ │
|
||||
│ └─→ 否 → 等待重连延迟 → 重试连接(循环)
|
||||
│
|
||||
└─→ 轮询监听(并行)
|
||||
↓
|
||||
定期轮询 (每 2 秒)
|
||||
↓
|
||||
查询 /activity 接口
|
||||
↓
|
||||
diff 检测新增交易
|
||||
↓
|
||||
调用 processTrade()(去重检查)
|
||||
↓
|
||||
继续轮询(循环)
|
||||
```
|
||||
|
||||
## 三、并行处理流程
|
||||
|
||||
```
|
||||
交易发生
|
||||
↓
|
||||
├─→ WS 监听(实时,秒级)
|
||||
│ ↓
|
||||
│ 收到链上日志
|
||||
│ ↓
|
||||
│ 解析并转换为 TradeResponse
|
||||
│ ↓
|
||||
│ 调用 processTrade()
|
||||
│ ↓
|
||||
│ 去重检查(processed_trade 表)
|
||||
│ ├─→ 已处理 → 跳过
|
||||
│ └─→ 未处理 → 处理交易
|
||||
│
|
||||
└─→ 轮询监听(延迟,2秒间隔)
|
||||
↓
|
||||
轮询到新交易
|
||||
↓
|
||||
转换为 TradeResponse
|
||||
↓
|
||||
调用 processTrade()
|
||||
↓
|
||||
去重检查(processed_trade 表)
|
||||
├─→ 已处理 → 跳过(WS 已处理)
|
||||
└─→ 未处理 → 处理交易
|
||||
```
|
||||
|
||||
## 三、实现方案
|
||||
|
||||
### 3.1 服务架构
|
||||
|
||||
```
|
||||
CopyTradingMonitorService (主服务)
|
||||
├─→ OnChainWsService (链上 WS 监听,独立运行)
|
||||
└─→ CopyTradingPollingService (轮询监听,独立运行)
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 两个服务**独立运行**,互不影响
|
||||
- 主服务负责启动和协调两个服务
|
||||
- 两个服务都调用同一个 `processTrade()` 方法
|
||||
- 去重由 `processTrade()` 内部处理
|
||||
|
||||
### 3.2 WS 重连机制
|
||||
|
||||
**重连策略**:
|
||||
1. WS 连接断开时,**不停止轮询服务**
|
||||
2. 等待重连延迟(如 3 秒)
|
||||
3. 自动重试连接
|
||||
4. 连接成功后重新订阅所有 Leader
|
||||
5. 如果连接失败,继续重试(无限重试)
|
||||
|
||||
**重连流程**:
|
||||
```
|
||||
WS 连接断开
|
||||
↓
|
||||
记录断开日志
|
||||
↓
|
||||
等待重连延迟(3秒)
|
||||
↓
|
||||
尝试重新连接
|
||||
↓
|
||||
连接成功?
|
||||
├─→ 是 → 重新订阅所有 Leader → 继续监听
|
||||
└─→ 否 → 等待重连延迟 → 继续重试(循环)
|
||||
```
|
||||
|
||||
**关键实现**:
|
||||
- 使用协程或后台线程持续重试
|
||||
- 不阻塞主流程
|
||||
- 轮询服务继续运行,不受 WS 状态影响
|
||||
|
||||
### 3.3 去重机制
|
||||
|
||||
**去重标识**:
|
||||
- 使用 `leaderId + transactionHash` 作为唯一标识
|
||||
- 在 `processed_trade` 表中记录已处理的交易
|
||||
|
||||
**去重流程**:
|
||||
```
|
||||
收到交易数据(WS 或轮询)
|
||||
↓
|
||||
调用 processTrade(leaderId, trade, source)
|
||||
↓
|
||||
检查 processed_trade 表
|
||||
├─→ 已存在 → 跳过处理(返回成功)
|
||||
└─→ 不存在 → 继续处理
|
||||
↓
|
||||
处理交易(创建订单等)
|
||||
↓
|
||||
保存到 processed_trade 表
|
||||
├─→ leaderId
|
||||
├─→ leaderTradeId (transactionHash)
|
||||
├─→ tradeType (BUY/SELL)
|
||||
├─→ source (onchain-ws / polling)
|
||||
└─→ status (SUCCESS/FAILED)
|
||||
```
|
||||
|
||||
**并发安全**:
|
||||
- 使用数据库唯一约束(`leaderId + leaderTradeId`)
|
||||
- 处理唯一约束冲突(并发情况下可能多个请求同时处理同一笔交易)
|
||||
- 如果冲突,再次查询确认状态
|
||||
|
||||
### 3.4 链上 WS 监听实现要点
|
||||
|
||||
**订阅参数**:
|
||||
- 钱包地址:Leader 的 `leaderAddress`
|
||||
- 订阅类型:
|
||||
- USDC Transfer(`from` 或 `to` 为钱包地址)
|
||||
- ERC1155 TransferSingle/Batch(`from` 或 `to` 为钱包地址)
|
||||
|
||||
**消息处理流程**:
|
||||
1. 接收 `eth_subscription` 消息
|
||||
2. 提取 `transactionHash`
|
||||
3. 调用 RPC 获取交易 receipt
|
||||
4. 解析 USDC Transfer 和 ERC1155 Transfer 日志
|
||||
5. 计算交易方向(BUY/SELL)、数量、价格
|
||||
6. 调用 Gamma API 补齐市场元数据(conditionId、outcomeIndex 等)
|
||||
7. 转换为 `TradeResponse`
|
||||
8. 调用 `processTrade(leaderId, trade, "onchain-ws")` 处理交易(去重由内部处理)
|
||||
|
||||
**重连实现**:
|
||||
```kotlin
|
||||
// 伪代码示例
|
||||
while (isActive) {
|
||||
try {
|
||||
// 尝试连接
|
||||
val ws = connectWebSocket()
|
||||
|
||||
// 订阅所有 Leader
|
||||
subscribeAllLeaders(ws)
|
||||
|
||||
// 监听消息
|
||||
ws.listen { message ->
|
||||
handleMessage(message)
|
||||
}
|
||||
|
||||
// 连接断开,等待重连
|
||||
waitReconnectDelay()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("WS 连接失败,等待重连: ${e.message}")
|
||||
waitReconnectDelay()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 轮询监听实现要点
|
||||
|
||||
**轮询流程**:
|
||||
1. 定期查询 `/activity` 接口(每 2 秒)
|
||||
2. 通过 diff 检测新增交易
|
||||
3. 转换为 `TradeResponse`
|
||||
4. 调用 `processTrade(leaderId, trade, "polling")` 处理交易(去重由内部处理)
|
||||
|
||||
**关键点**:
|
||||
- 轮询服务**独立运行**,不受 WS 状态影响
|
||||
- 即使 WS 正常工作,轮询也继续运行(作为备份)
|
||||
- 去重机制确保不会重复处理同一笔交易
|
||||
|
||||
## 四、配置参数
|
||||
|
||||
### 4.1 RPC 配置获取
|
||||
|
||||
**RPC 配置从后台配置中读取**,通过 `RpcNodeService` 获取:
|
||||
|
||||
```kotlin
|
||||
// 获取 HTTP RPC URL
|
||||
val httpUrl = rpcNodeService.getHttpUrl()
|
||||
|
||||
// 获取 WebSocket RPC URL
|
||||
val wsUrl = rpcNodeService.getWsUrl()
|
||||
|
||||
// 获取可用节点配置(包含完整信息)
|
||||
val nodeResult = rpcNodeService.getAvailableNode()
|
||||
if (nodeResult.isSuccess) {
|
||||
val node = nodeResult.getOrNull()
|
||||
val httpUrl = node?.httpUrl
|
||||
val wsUrl = node?.wsUrl
|
||||
}
|
||||
```
|
||||
|
||||
**配置管理**:
|
||||
- **RPC 节点配置存储在数据库**(`rpc_node_config` 表),不从配置文件读取
|
||||
- 支持多个节点配置,按优先级选择
|
||||
- 支持健康检查,自动选择可用节点
|
||||
- 前端可以通过系统设置页面配置 RPC 节点
|
||||
- 配置变更后,WS 重连时会自动使用新配置
|
||||
|
||||
**配置字段**:
|
||||
- `httpUrl`: HTTP RPC 地址
|
||||
- `wsUrl`: WebSocket RPC 地址(可选)
|
||||
- `enabled`: 是否启用
|
||||
- `priority`: 优先级(数字越小优先级越高)
|
||||
- `lastCheckStatus`: 最后检查状态(HEALTHY/UNHEALTHY/UNKNOWN)
|
||||
|
||||
### 4.2 WS 连接配置
|
||||
|
||||
```properties
|
||||
# WS 连接超时(毫秒)
|
||||
polygen.ws.connect.timeout=5000
|
||||
|
||||
# WS 重连延迟(毫秒)
|
||||
polygen.ws.reconnect.delay=3000
|
||||
```
|
||||
|
||||
### 4.3 WS 重连配置
|
||||
|
||||
```properties
|
||||
# WS 重连延迟(毫秒)
|
||||
polygen.ws.reconnect.delay=3000
|
||||
|
||||
# WS 重连最大延迟(毫秒,指数退避上限)
|
||||
polygen.ws.reconnect.max.delay=60000
|
||||
|
||||
# WS 重连是否启用指数退避
|
||||
polygen.ws.reconnect.exponential.backoff=true
|
||||
```
|
||||
|
||||
### 4.4 合约地址配置
|
||||
|
||||
```properties
|
||||
# USDC 合约地址
|
||||
usdc.contract.address=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
|
||||
# ERC1155 合约地址(Polymarket)
|
||||
erc1155.contract.address=0x4d97dcd97ec945f40cf65f87097ace5ea0476045
|
||||
```
|
||||
|
||||
## 五、并行方案优势
|
||||
|
||||
### 5.1 双重保障
|
||||
|
||||
- **实时性**:WS 提供秒级实时通知
|
||||
- **可靠性**:轮询作为备份,确保不遗漏交易
|
||||
- **容错性**:WS 断开时轮询继续工作,WS 恢复后自动继续
|
||||
|
||||
### 5.2 性能优化
|
||||
|
||||
- **最快响应**:哪个数据先返回就用哪个
|
||||
- **负载均衡**:两种方式并行,减少单点压力
|
||||
- **去重保护**:确保同一笔交易只处理一次
|
||||
|
||||
### 5.3 实现简单
|
||||
|
||||
- **无需切换逻辑**:两种方式始终运行
|
||||
- **独立管理**:WS 和轮询各自管理自己的状态
|
||||
- **易于维护**:逻辑清晰,易于调试
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. **WS 重连策略**:
|
||||
- WS 断开时**不断重试**,不停止轮询
|
||||
- 使用指数退避策略,避免频繁重连
|
||||
- 记录重连日志,便于监控
|
||||
|
||||
2. **去重机制**:
|
||||
- 使用 `leaderId + transactionHash` 作为唯一标识
|
||||
- 在 `processTrade()` 方法内部统一处理去重
|
||||
- 使用数据库唯一约束确保并发安全
|
||||
|
||||
3. **性能考虑**:
|
||||
- WS 模式下需要为每个 Leader 订阅日志
|
||||
- 大量 Leader 时可能需要优化订阅策略(批量订阅)
|
||||
- 轮询间隔可以适当调整(默认 2 秒)
|
||||
|
||||
4. **错误处理**:
|
||||
- WS 连接失败不影响轮询服务
|
||||
- 记录详细的错误日志,便于排查问题
|
||||
- 两种方式的错误独立处理,互不影响
|
||||
|
||||
5. **元数据补齐**:
|
||||
- 链上数据不包含市场元数据(conditionId、outcomeIndex 等)
|
||||
- 需要通过 Gamma API 或内部元数据服务补齐
|
||||
- 轮询数据已包含元数据,无需额外补齐
|
||||
|
||||
6. **数据源标识**:
|
||||
- WS 数据:`source = "onchain-ws"`
|
||||
- 轮询数据:`source = "polling"`
|
||||
- 便于统计和分析不同数据源的处理情况
|
||||
|
||||
## 七、实现要点
|
||||
|
||||
### 7.1 主服务启动
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class CopyTradingMonitorService(
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val onChainWsService: OnChainWsService,
|
||||
private val pollingService: CopyTradingPollingService
|
||||
) {
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
scope.launch {
|
||||
// 同时启动两种监听方式
|
||||
launch { onChainWsService.start() } // WS 监听(独立协程)
|
||||
launch { pollingService.start() } // 轮询监听(独立协程)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 WS 服务实现(从后台配置获取 RPC)
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class OnChainWsService(
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService
|
||||
) {
|
||||
suspend fun start() {
|
||||
while (isActive) {
|
||||
try {
|
||||
// 从后台配置获取 WS RPC URL
|
||||
val wsUrl = rpcNodeService.getWsUrl()
|
||||
val httpUrl = rpcNodeService.getHttpUrl()
|
||||
|
||||
// 连接并订阅
|
||||
connectAndSubscribe(wsUrl, httpUrl)
|
||||
|
||||
// 连接成功后持续监听
|
||||
waitForDisconnect()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("WS 连接失败,等待重连: ${e.message}")
|
||||
delay(reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectAndSubscribe(wsUrl: String, httpUrl: String) {
|
||||
// 使用 wsUrl 和 httpUrl 连接和订阅
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 每次重连时都从 `RpcNodeService` 获取最新的 RPC 配置
|
||||
- 如果配置变更,下次重连时会自动使用新配置
|
||||
- 支持多个节点配置,自动选择可用节点
|
||||
|
||||
### 7.3 RPC 配置变更处理
|
||||
|
||||
**配置变更时的处理**:
|
||||
- WS 连接断开时,下次重连会从 `RpcNodeService` 获取最新配置
|
||||
- 如果当前使用的节点不可用,`getAvailableNode()` 会自动选择下一个可用节点
|
||||
- 支持动态切换节点,无需重启服务
|
||||
|
||||
**节点选择策略**:
|
||||
1. 优先使用 `lastCheckStatus = HEALTHY` 的节点
|
||||
2. 按 `priority` 排序,数字越小优先级越高
|
||||
3. 如果所有节点都不可用,返回失败(不降级到默认节点,因为默认节点可能也不可用)
|
||||
|
||||
### 7.4 线程安全保证(单实例推荐方案)
|
||||
|
||||
`processTrade` 方法需要保证线程安全,因为:
|
||||
- WS 和轮询可能同时处理同一笔交易
|
||||
- 多个协程可能并发调用 `processTrade`
|
||||
- 需要确保同一笔交易只处理一次
|
||||
|
||||
**推荐方案:使用 Mutex(应用级锁)**
|
||||
|
||||
对于单实例部署,**最轻量的方案是使用 Kotlin 协程的 Mutex**:
|
||||
- ✅ 无需额外依赖(不需要 Redis)
|
||||
- ✅ 性能开销小(内存锁,无网络开销)
|
||||
- ✅ 实现简单(Kotlin 标准库)
|
||||
- ✅ 协程友好(支持 suspend 函数)
|
||||
|
||||
**实现代码**:
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
open class CopyOrderTrackingService(
|
||||
// ... 其他依赖
|
||||
) {
|
||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
/**
|
||||
* 获取或创建 Mutex(按交易ID)
|
||||
*/
|
||||
private fun getMutex(leaderId: Long, tradeId: String): Mutex {
|
||||
val key = "${leaderId}_${tradeId}"
|
||||
return tradeMutexMap.getOrPut(key) { Mutex() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理不再使用的 Mutex(可选,避免内存泄漏)
|
||||
*/
|
||||
private fun cleanupMutex(leaderId: Long, tradeId: String) {
|
||||
val key = "${leaderId}_${tradeId}"
|
||||
// 延迟清理,避免频繁创建/删除
|
||||
// 可以定期清理或使用 WeakReference
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易事件(WebSocket 或轮询)
|
||||
* 使用 Mutex 保证线程安全
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||
// 获取该交易的 Mutex
|
||||
val mutex = getMutex(leaderId, trade.id)
|
||||
|
||||
return mutex.withLock {
|
||||
try {
|
||||
// 1. 检查是否已处理(去重)
|
||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
|
||||
leaderId,
|
||||
trade.id
|
||||
)
|
||||
|
||||
if (existingProcessed != null) {
|
||||
if (existingProcessed.status == "FAILED") {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 检查是否已记录为失败交易
|
||||
val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(
|
||||
leaderId,
|
||||
trade.id
|
||||
)
|
||||
if (failedTrade != null) {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
logger.error(
|
||||
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
|
||||
result.exceptionOrNull()
|
||||
)
|
||||
return@withLock result
|
||||
}
|
||||
|
||||
// 3. 标记为已处理(成功状态)
|
||||
// 由于使用了 Mutex,这里不会出现并发冲突
|
||||
try {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
tradeType = trade.side.uppercase(),
|
||||
source = source,
|
||||
status = "SUCCESS",
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: Exception) {
|
||||
// 理论上不会发生,但保留异常处理作为兜底
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
|
||||
leaderId,
|
||||
trade.id
|
||||
)
|
||||
if (existing != null) {
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
1. **按交易ID锁定**:每个交易使用独立的 Mutex,不同交易可以并行处理
|
||||
2. **Mutex 复用**:使用 `ConcurrentHashMap` 缓存 Mutex,避免频繁创建
|
||||
3. **协程友好**:`Mutex.withLock` 是 suspend 函数,不会阻塞线程
|
||||
4. **性能优化**:只锁定同一笔交易,不影响其他交易的并发处理
|
||||
|
||||
**当前实现(基于数据库唯一约束,作为兜底)**:
|
||||
|
||||
```kotlin
|
||||
@Transactional
|
||||
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||
// 1. 检查是否已处理(去重)
|
||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
|
||||
leaderId,
|
||||
trade.id
|
||||
)
|
||||
|
||||
if (existingProcessed != null) {
|
||||
return Result.success(Unit) // 已处理,跳过
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> Result.failure(IllegalArgumentException("未知的交易方向"))
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. 标记为已处理(使用数据库唯一约束保证并发安全)
|
||||
try {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
tradeType = trade.side.uppercase(),
|
||||
source = source,
|
||||
status = "SUCCESS",
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: Exception) {
|
||||
// 处理唯一约束冲突(并发情况下可能发生)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
// 再次检查确认状态
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(
|
||||
leaderId,
|
||||
trade.id
|
||||
)
|
||||
if (existing != null) {
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
```
|
||||
|
||||
**线程安全机制**:
|
||||
|
||||
1. **数据库唯一约束**:
|
||||
- `UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id)`
|
||||
- 确保同一笔交易只能插入一次
|
||||
- 即使两个线程同时通过检查,也只有一个能成功保存
|
||||
|
||||
2. **事务隔离**:
|
||||
- 使用 `@Transactional` 注解
|
||||
- 默认隔离级别(通常是 READ_COMMITTED)
|
||||
- 保证事务内的数据一致性
|
||||
|
||||
3. **异常处理**:
|
||||
- 捕获唯一约束冲突异常
|
||||
- 重新查询确认状态
|
||||
- 避免重复处理
|
||||
|
||||
**潜在问题**:
|
||||
|
||||
虽然数据库唯一约束可以防止重复插入,但在检查 `existingProcessed` 和保存 `processed` 之间存在时间窗口(TOCTOU),可能导致:
|
||||
- 两个线程同时通过检查
|
||||
- 两个线程都执行 `processBuyTrade` 或 `processSellTrade`
|
||||
- 虽然只有一个能成功保存 `processed`,但可能创建了重复的订单
|
||||
|
||||
**其他方案(不推荐,仅作参考)**:
|
||||
|
||||
**方案 1:使用数据库锁(SELECT FOR UPDATE)**
|
||||
- ❌ 需要修改 Repository 方法
|
||||
- ❌ 增加数据库负载
|
||||
- ❌ 不适合高并发场景
|
||||
|
||||
**方案 2:使用分布式锁(Redis,仅适用于多实例)**
|
||||
- ❌ 需要 Redis 依赖
|
||||
- ❌ 有网络开销
|
||||
- ❌ 单实例场景不需要
|
||||
|
||||
**总结**:
|
||||
|
||||
- ✅ **单实例部署推荐**:使用 **Mutex(应用级锁)**
|
||||
- 最轻量:无需额外依赖,性能开销小
|
||||
- 最简单:Kotlin 标准库,实现简单
|
||||
- 最高效:内存锁,无网络开销,支持协程并发
|
||||
- ❌ **不推荐**:仅依赖数据库唯一约束(存在 TOCTOU 问题,可能创建重复订单)
|
||||
- ❌ **不推荐**:数据库锁或分布式锁(单实例场景过于复杂)
|
||||
|
||||
@@ -32,6 +32,7 @@ import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||
import FilteredOrdersList from './pages/FilteredOrdersList'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||
import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||
import Announcements from './pages/Announcements'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
@@ -263,7 +264,7 @@ function App() {
|
||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
|
||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
||||
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Radio, Space, Alert } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import {
|
||||
getAddressFromPrivateKey,
|
||||
getAddressFromMnemonic,
|
||||
getPrivateKeyFromMnemonic,
|
||||
isValidWalletAddress,
|
||||
isValidPrivateKey,
|
||||
isValidMnemonic
|
||||
} from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
|
||||
interface AccountImportFormProps {
|
||||
form: any
|
||||
onSuccess?: (accountId: number) => void
|
||||
onCancel?: () => void
|
||||
showAlert?: boolean
|
||||
showCancelButton?: boolean
|
||||
}
|
||||
|
||||
const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
form,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
showAlert = true,
|
||||
showCancelButton = true
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { importAccount, loading } = useAccountStore()
|
||||
const [importType, setImportType] = useState<ImportType>('privateKey')
|
||||
const [derivedAddress, setDerivedAddress] = useState<string>('')
|
||||
const [addressError, setAddressError] = useState<string>('')
|
||||
|
||||
// 当私钥输入时,自动推导地址
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const privateKey = e.target.value.trim()
|
||||
if (!privateKey) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证私钥格式
|
||||
if (!isValidPrivateKey(privateKey)) {
|
||||
setAddressError(t('accountImport.privateKeyInvalid'))
|
||||
setDerivedAddress('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const address = getAddressFromPrivateKey(privateKey)
|
||||
setDerivedAddress(address)
|
||||
setAddressError('')
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressError'))
|
||||
setDerivedAddress('')
|
||||
}
|
||||
}
|
||||
|
||||
// 当助记词输入时,自动推导地址
|
||||
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const mnemonic = e.target.value.trim()
|
||||
if (!mnemonic) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证助记词格式
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
setAddressError(t('accountImport.mnemonicInvalid'))
|
||||
setDerivedAddress('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const address = getAddressFromMnemonic(mnemonic, 0)
|
||||
setDerivedAddress(address)
|
||||
setAddressError('')
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
|
||||
setDerivedAddress('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
let privateKey: string
|
||||
let walletAddress: string
|
||||
|
||||
if (importType === 'privateKey') {
|
||||
// 私钥模式
|
||||
privateKey = values.privateKey
|
||||
walletAddress = values.walletAddress
|
||||
|
||||
// 验证推导的地址和输入的地址是否一致
|
||||
if (derivedAddress && walletAddress !== derivedAddress) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressMismatch')))
|
||||
}
|
||||
} else {
|
||||
// 助记词模式
|
||||
if (!values.mnemonic) {
|
||||
return Promise.reject(new Error(t('accountImport.mnemonicRequired')))
|
||||
}
|
||||
|
||||
// 从助记词导出私钥和地址
|
||||
privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0)
|
||||
const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0)
|
||||
|
||||
// 如果用户手动输入了地址,验证是否与推导的地址一致
|
||||
if (values.walletAddress) {
|
||||
if (values.walletAddress !== derivedAddressFromMnemonic) {
|
||||
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
} else {
|
||||
// 地址匹配,使用用户输入的地址
|
||||
walletAddress = values.walletAddress
|
||||
}
|
||||
} else {
|
||||
// 如果用户没有输入地址,使用推导的地址
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
}
|
||||
}
|
||||
|
||||
// 验证钱包地址格式
|
||||
if (!isValidWalletAddress(walletAddress)) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressInvalid')))
|
||||
}
|
||||
|
||||
await importAccount({
|
||||
privateKey: privateKey,
|
||||
walletAddress: walletAddress,
|
||||
accountName: values.accountName
|
||||
})
|
||||
|
||||
// 等待store更新
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
||||
const { apiService } = await import('../services/api')
|
||||
const accountsResponse = await apiService.accounts.list()
|
||||
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
||||
const newAccounts = accountsResponse.data.data.list || []
|
||||
const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress)
|
||||
if (newAccount && onSuccess) {
|
||||
onSuccess(newAccount.id)
|
||||
} else if (onSuccess) {
|
||||
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
|
||||
onSuccess(0)
|
||||
}
|
||||
} else if (onSuccess) {
|
||||
// API调用失败,仍然调用onSuccess
|
||||
onSuccess(0)
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
} catch (error: any) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAlert && (
|
||||
<Alert
|
||||
message={t('accountImport.securityTip')}
|
||||
description={t('accountImport.securityTipDesc')}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item label={t('accountImport.importMethod')}>
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value)
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
form.setFieldsValue({ walletAddress: '' })
|
||||
}}
|
||||
>
|
||||
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
|
||||
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{importType === 'privateKey' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('accountImport.privateKeyLabel')}
|
||||
name="privateKey"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.privateKeyRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidPrivateKey(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.privateKeyInvalid')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder={t('accountImport.privateKeyPlaceholder')}
|
||||
onChange={handlePrivateKeyChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.walletAddress')}
|
||||
name="walletAddress"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.walletAddressRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidWalletAddress(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressInvalid')))
|
||||
}
|
||||
if (derivedAddress && value !== derivedAddress) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressMismatch')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('accountImport.mnemonicLabel')}
|
||||
name="mnemonic"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.mnemonicRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidMnemonic(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.mnemonicInvalid')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={t('accountImport.mnemonicPlaceholder')}
|
||||
onChange={handleMnemonicChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.walletAddress')}
|
||||
name="walletAddress"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.walletAddressRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidWalletAddress(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressInvalid')))
|
||||
}
|
||||
if (derivedAddress && value !== derivedAddress) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressMismatchMnemonic')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.accountName')}
|
||||
name="accountName"
|
||||
>
|
||||
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{t('accountImport.importAccount')}
|
||||
</Button>
|
||||
{showCancelButton && onCancel && (
|
||||
<Button onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountImportForm
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal, Form, Input, Select, message, Space } from 'antd'
|
||||
import { LinkOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { RpcNodeAddRequest } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface AddRpcNodeModalProps {
|
||||
visible: boolean
|
||||
onCancel: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const AddRpcNodeModal: React.FC<AddRpcNodeModalProps> = ({ visible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm()
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('CUSTOM')
|
||||
const [validating, setValidating] = useState(false)
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'ALCHEMY', label: t('rpcNodeSettings.providerAlchemy'), url: 'https://dashboard.alchemy.com/' },
|
||||
{ value: 'INFURA', label: t('rpcNodeSettings.providerInfura'), url: 'https://infura.io/' },
|
||||
{ value: 'QUICKNODE', label: t('rpcNodeSettings.providerQuickNode'), url: 'https://www.quicknode.com/' },
|
||||
{ value: 'CHAINSTACK', label: t('rpcNodeSettings.providerChainstack'), url: 'https://chainstack.com/' },
|
||||
{ value: 'GETBLOCK', label: t('rpcNodeSettings.providerGetBlock'), url: 'https://getblock.io/' },
|
||||
{ value: 'CUSTOM', label: t('rpcNodeSettings.customNode'), url: '' }
|
||||
]
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setValidating(true)
|
||||
|
||||
const request: RpcNodeAddRequest = {
|
||||
providerType: values.providerType,
|
||||
name: values.name,
|
||||
apiKey: values.apiKey,
|
||||
httpUrl: values.httpUrl,
|
||||
wsUrl: values.wsUrl
|
||||
}
|
||||
|
||||
// 先验证节点
|
||||
const validateResponse = await apiService.rpcNodes.validate(request)
|
||||
|
||||
if (validateResponse.data.code === 0 && validateResponse.data.data) {
|
||||
const result = validateResponse.data.data
|
||||
|
||||
if (!result.valid) {
|
||||
message.error(`${t('rpcNodeSettings.validateFailed')} ${result.message}`)
|
||||
setValidating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证通过,添加节点
|
||||
const addResponse = await apiService.rpcNodes.add(request)
|
||||
|
||||
if (addResponse.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.addSuccess'))
|
||||
form.resetFields()
|
||||
setSelectedProvider('CUSTOM')
|
||||
onSuccess()
|
||||
} else {
|
||||
message.error(addResponse.data.msg || t('rpcNodeSettings.addFailed'))
|
||||
}
|
||||
} else {
|
||||
message.error(validateResponse.data.msg || t('rpcNodeSettings.validateError'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!error.errorFields) {
|
||||
message.error(error.message || t('rpcNodeSettings.operationFailed'))
|
||||
}
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields()
|
||||
setSelectedProvider('CUSTOM')
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const currentProvider = providerOptions.find(p => p.value === selectedProvider)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('rpcNodeSettings.addNodeTitle')}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
width={600}
|
||||
confirmLoading={validating}
|
||||
okText={t('rpcNodeSettings.validateAndAdd')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ providerType: 'CUSTOM' }}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.providerTypeLabel')}
|
||||
name="providerType"
|
||||
rules={[{ required: true, message: t('rpcNodeSettings.providerTypeRequired') }]}
|
||||
>
|
||||
<Select onChange={setSelectedProvider}>
|
||||
{providerOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.nodeNameLabel')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('rpcNodeSettings.nodeNameRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('rpcNodeSettings.nodeNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
{selectedProvider !== 'CUSTOM' && (
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
<span>{t('rpcNodeSettings.apiKeyLabel')}</span>
|
||||
{currentProvider?.url && (
|
||||
<a href={currentProvider.url} target="_blank" rel="noopener noreferrer">
|
||||
<LinkOutlined /> {t('rpcNodeSettings.getApiKey')}
|
||||
</a>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
name="apiKey"
|
||||
rules={[{ required: true, message: t('rpcNodeSettings.apiKeyRequired') }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={t('rpcNodeSettings.apiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{selectedProvider === 'CUSTOM' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.httpUrlLabel')}
|
||||
name="httpUrl"
|
||||
rules={[
|
||||
{ required: true, message: t('rpcNodeSettings.httpUrlRequired') },
|
||||
{ type: 'url', message: t('rpcNodeSettings.httpUrlInvalid') }
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('rpcNodeSettings.httpUrlPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.wsUrlLabel')}
|
||||
name="wsUrl"
|
||||
rules={[{ type: 'url', message: t('rpcNodeSettings.wsUrlInvalid') }]}
|
||||
>
|
||||
<Input placeholder={t('rpcNodeSettings.wsUrlPlaceholder')} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRpcNodeModal
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TwitterOutlined,
|
||||
CheckCircleOutlined,
|
||||
SendOutlined,
|
||||
NotificationOutlined
|
||||
ApiOutlined, NotificationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -131,6 +131,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <SettingOutlined />,
|
||||
label: t('menu.systemOverview') || '通用设置'
|
||||
},
|
||||
{
|
||||
key: '/system-settings/rpc-nodes',
|
||||
icon: <ApiOutlined />,
|
||||
label: t('menu.rpcNodes') || 'RPC节点管理'
|
||||
},
|
||||
{
|
||||
key: '/system-settings/api-health',
|
||||
icon: <CheckCircleOutlined />,
|
||||
@@ -238,7 +243,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/quant_tr"
|
||||
href="https://x.com/polyhermes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
|
||||
@@ -362,7 +367,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/quant_tr"
|
||||
href="https://x.com/polyhermes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Space } from 'antd'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isValidWalletAddress } from '../utils'
|
||||
|
||||
interface LeaderAddFormProps {
|
||||
form: any
|
||||
onSuccess?: (leaderId: number) => void
|
||||
onCancel?: () => void
|
||||
showCancelButton?: boolean
|
||||
}
|
||||
|
||||
const LeaderAddForm: React.FC<LeaderAddFormProps> = ({
|
||||
form,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
showCancelButton = true
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaders.add({
|
||||
leaderAddress: values.leaderAddress.trim(),
|
||||
leaderName: values.leaderName?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
website: values.website?.trim() || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
if (response.data.data && onSuccess) {
|
||||
onSuccess(response.data.data.id)
|
||||
}
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
return Promise.reject(new Error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('leaderAdd.leaderAddress') || 'Leader 钱包地址'}
|
||||
name="leaderAddress"
|
||||
rules={[
|
||||
{ required: true, message: t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址'))
|
||||
}
|
||||
if (!isValidWalletAddress(value.trim())) {
|
||||
return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'}
|
||||
>
|
||||
<Input placeholder="0x..." style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.leaderName') || 'Leader 名称'}
|
||||
name="leaderName"
|
||||
tooltip={t('leaderAdd.leaderNameTooltip') || '可选,用于标识 Leader,方便管理'}
|
||||
>
|
||||
<Input placeholder={t('leaderAdd.leaderNamePlaceholder') || '可选,用于标识 Leader'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.remark') || 'Leader 备注'}
|
||||
name="remark"
|
||||
tooltip={t('leaderAdd.remarkTooltip') || '可选,用于记录 Leader 的备注信息'}
|
||||
>
|
||||
<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>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{t('leaderAdd.add') || '添加 Leader'}
|
||||
</Button>
|
||||
{showCancelButton && onCancel && (
|
||||
<Button onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderAddForm
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"updateSuccess": "Account updated successfully",
|
||||
"updateFailed": "Failed to update account",
|
||||
"getDetailFailed": "Failed to get account details",
|
||||
"accountIdRequired": "Account ID cannot be empty"
|
||||
"accountIdRequired": "Account ID cannot be empty",
|
||||
"proxyAddress": "Proxy Wallet Address"
|
||||
},
|
||||
"message": {
|
||||
"success": "Operation successful",
|
||||
@@ -236,6 +237,7 @@
|
||||
"systemSettings": "System",
|
||||
"systemOverview": "Overview",
|
||||
"language": "Language",
|
||||
"rpcNodes": "RPC Nodes",
|
||||
"apiHealth": "API Health",
|
||||
"builderApiKey": "Builder API Key",
|
||||
"proxy": "Proxy",
|
||||
@@ -426,6 +428,7 @@
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "Add Leader",
|
||||
"back": "Back",
|
||||
"leaderAddress": "Leader Wallet Address",
|
||||
"leaderAddressRequired": "Please enter Leader wallet address",
|
||||
"leaderAddressInvalid": "Invalid wallet address format (must be a 42-character address starting with 0x)",
|
||||
@@ -667,6 +670,13 @@
|
||||
"priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.",
|
||||
"minPricePlaceholder": "Min Price (leave empty for no limit)",
|
||||
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
|
||||
"positionLimitFilter": "Max Position Limit",
|
||||
"maxPositionValue": "Max Position Value (USDC)",
|
||||
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxPositionCount": "Max Position Count",
|
||||
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"supportSell": "Support Sell",
|
||||
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
|
||||
"invalidNumber": "Please enter a valid number"
|
||||
@@ -732,6 +742,13 @@
|
||||
"priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.",
|
||||
"minPricePlaceholder": "Min Price (leave empty for no limit)",
|
||||
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
|
||||
"positionLimitFilter": "Max Position Limit",
|
||||
"maxPositionValue": "Max Position Value (USDC)",
|
||||
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxPositionCount": "Max Position Count",
|
||||
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"configName": "Configuration Name",
|
||||
"configNameRequired": "Please enter configuration name",
|
||||
"configNamePlaceholder": "e.g., Copy Trading Config 1",
|
||||
@@ -752,7 +769,11 @@
|
||||
"invalidNumber": "Please enter a valid number",
|
||||
"fetchLeaderFailed": "Failed to get Leader list",
|
||||
"fetchTemplateFailed": "Failed to get template list",
|
||||
"templateName": "Template Name"
|
||||
"templateName": "Template Name",
|
||||
"noAccounts": "No accounts",
|
||||
"importAccount": "Import Account",
|
||||
"noLeaders": "No Leaders",
|
||||
"addLeader": "Add Leader"
|
||||
},
|
||||
"copyTradingEdit": {
|
||||
"title": "Edit Copy Trading Config",
|
||||
@@ -807,6 +828,13 @@
|
||||
"priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.",
|
||||
"minPricePlaceholder": "Min Price (leave empty for no limit)",
|
||||
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
|
||||
"positionLimitFilter": "Max Position Limit",
|
||||
"maxPositionValue": "Max Position Value (USDC)",
|
||||
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxPositionCount": "Max Position Count",
|
||||
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"configName": "Configuration Name",
|
||||
"configNameRequired": "Please enter configuration name",
|
||||
"configNamePlaceholder": "e.g., Copy Trading Config 1",
|
||||
@@ -849,6 +877,8 @@
|
||||
"orderbookError": "Orderbook Fetch Failed",
|
||||
"orderbookEmpty": "Orderbook Empty",
|
||||
"priceRange": "Price Range Mismatch",
|
||||
"maxPositionValue": "Exceeds Max Position Value",
|
||||
"maxPositionCount": "Exceeds Max Position Count",
|
||||
"unknown": "Unknown Reason"
|
||||
},
|
||||
"noData": "No filtered orders"
|
||||
@@ -856,6 +886,8 @@
|
||||
"copyTradingList": {
|
||||
"title": "Copy Trading Config Management",
|
||||
"addCopyTrading": "Add Copy Trading",
|
||||
"configName": "Config Name",
|
||||
"configNameNotProvided": "Not Provided",
|
||||
"wallet": "Wallet",
|
||||
"account": "Account",
|
||||
"template": "Template",
|
||||
@@ -1034,5 +1066,66 @@
|
||||
"averagePnl": "Average PnL",
|
||||
"maxPnl": "Max PnL",
|
||||
"minPnl": "Min PnL"
|
||||
},
|
||||
"rpcNodeSettings": {
|
||||
"title": "Polygon RPC Node Configuration",
|
||||
"fetchFailed": "Failed to fetch node list",
|
||||
"checkHealthSuccess": "Health check completed",
|
||||
"checkHealthFailed": "Health check failed",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"adjustPrioritySuccess": "Priority adjusted successfully",
|
||||
"adjustPriorityFailed": "Failed to adjust priority",
|
||||
"priority": "Priority",
|
||||
"providerType": "Provider",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"statusHealthy": "Available",
|
||||
"statusUnhealthy": "Unavailable",
|
||||
"statusUnknown": "Unknown",
|
||||
"responseTime": "Response Time",
|
||||
"enabled": "Enabled",
|
||||
"actions": "Actions",
|
||||
"check": "Check",
|
||||
"enableSuccess": "Enabled successfully",
|
||||
"disableSuccess": "Disabled successfully",
|
||||
"updateFailed": "Update failed",
|
||||
"checkSuccess": "Check completed",
|
||||
"checkFailed": "Check failed",
|
||||
"deleteConfirm": "Are you sure you want to delete this node?",
|
||||
"deleteConfirmOk": "Confirm",
|
||||
"deleteConfirmCancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"batchCheck": "Batch Check",
|
||||
"addNode": "Add Node",
|
||||
"addNodeTitle": "Add RPC Node",
|
||||
"validateAndAdd": "Validate and Add",
|
||||
"providerTypeLabel": "Provider Type",
|
||||
"providerTypeRequired": "Please select provider type",
|
||||
"nodeNameLabel": "Node Name",
|
||||
"nodeNameRequired": "Please enter node name",
|
||||
"nodeNamePlaceholder": "e.g.: My Alchemy Node",
|
||||
"apiKeyLabel": "API Key",
|
||||
"getApiKey": "Get API Key",
|
||||
"apiKeyRequired": "Please enter API Key",
|
||||
"apiKeyPlaceholder": "Enter your API Key",
|
||||
"httpUrlLabel": "HTTP RPC URL",
|
||||
"httpUrlRequired": "Please enter HTTP RPC URL",
|
||||
"httpUrlInvalid": "Please enter a valid URL",
|
||||
"httpUrlPlaceholder": "https://polygon-rpc.com",
|
||||
"wsUrlLabel": "WebSocket URL (Optional)",
|
||||
"wsUrlInvalid": "Please enter a valid URL",
|
||||
"wsUrlPlaceholder": "wss://polygon-rpc.com",
|
||||
"validateFailed": "Node validation failed:",
|
||||
"addSuccess": "Added successfully",
|
||||
"addFailed": "Failed to add",
|
||||
"validateError": "Validation failed",
|
||||
"operationFailed": "Operation failed",
|
||||
"customNode": "Custom Node",
|
||||
"providerAlchemy": "Alchemy",
|
||||
"providerInfura": "Infura",
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
"total": "共",
|
||||
"items": "条",
|
||||
"prev": "上一页",
|
||||
"next": "下一页"
|
||||
"next": "下一页",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"close": "关闭"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
@@ -45,7 +48,13 @@
|
||||
"success": "操作成功",
|
||||
"error": "操作失败",
|
||||
"loading": "加载中...",
|
||||
"noData": "暂无数据"
|
||||
"noData": "暂无数据",
|
||||
"loginSuccess": "登录成功",
|
||||
"loginFailed": "登录失败",
|
||||
"createUserSuccess": "创建用户成功",
|
||||
"createUserFailed": "创建用户失败",
|
||||
"updatePasswordSuccess": "更新密码成功",
|
||||
"updatePasswordFailed": "更新密码失败"
|
||||
},
|
||||
"order": {
|
||||
"create": "创建订单",
|
||||
@@ -53,7 +62,47 @@
|
||||
"cancel": "取消订单",
|
||||
"event": "订单事件",
|
||||
"buy": "买入",
|
||||
"sell": "卖出"
|
||||
"sell": "卖出",
|
||||
"market": "市场",
|
||||
"status": "状态",
|
||||
"filled": "已成交",
|
||||
"remaining": "剩余"
|
||||
},
|
||||
"account": {
|
||||
"title": "账户管理",
|
||||
"list": "账户列表",
|
||||
"detail": "账户详情",
|
||||
"import": "导入账户",
|
||||
"update": "更新账户",
|
||||
"delete": "删除账户",
|
||||
"accountId": "账户ID",
|
||||
"accountName": "账户名称",
|
||||
"accountNamePlaceholder": "账户名称(可选)",
|
||||
"accountIdRequired": "账户ID不能为空",
|
||||
"walletAddress": "钱包地址",
|
||||
"proxyAddress": "代理钱包地址",
|
||||
"apiCredentials": "API 凭证",
|
||||
"apiKey": "API Key",
|
||||
"apiSecret": "API Secret",
|
||||
"apiPassphrase": "API Passphrase",
|
||||
"balance": "余额",
|
||||
"activeOrders": "活跃订单",
|
||||
"completedOrders": "已完成订单",
|
||||
"positionCount": "持仓数量",
|
||||
"totalOrders": "总订单数",
|
||||
"totalPnl": "总盈亏",
|
||||
"statistics": "交易统计",
|
||||
"fullConfig": "完整配置",
|
||||
"partialConfig": "部分配置",
|
||||
"notConfigured": "未配置",
|
||||
"configured": "已配置",
|
||||
"editTip": "编辑提示",
|
||||
"editTipDesc": "API 凭证字段留空表示不修改。如需更新 API 凭证,请输入新值;如需保持原值不变,请留空。",
|
||||
"leaveEmptyToNotModify": "留空表示不修改",
|
||||
"getDetailFailed": "获取账户详情失败",
|
||||
"updateSuccess": "更新账户成功",
|
||||
"updateFailed": "更新账户失败",
|
||||
"refreshBalance": "刷新余额"
|
||||
},
|
||||
"accountList": {
|
||||
"title": "账户管理",
|
||||
@@ -117,28 +166,63 @@
|
||||
},
|
||||
"accountImport": {
|
||||
"title": "导入账户",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
|
||||
"importMethod": "导入方式",
|
||||
"privateKey": "私钥",
|
||||
"mnemonic": "助记词",
|
||||
"privateKeyLabel": "私钥",
|
||||
"privateKeyPlaceholder": "请输入私钥(64位十六进制字符串,可选0x前缀)",
|
||||
"privateKeyRequired": "请输入私钥",
|
||||
"privateKeyPlaceholder": "请输入或粘贴私钥",
|
||||
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
|
||||
"privateKeyInvalid": "私钥格式不正确(应为64位十六进制字符串)",
|
||||
"walletAddress": "钱包地址",
|
||||
"walletAddressPlaceholder": "钱包地址(将从私钥自动推导)",
|
||||
"walletAddressRequired": "请输入钱包地址",
|
||||
"walletAddressInvalid": "钱包地址格式不正确",
|
||||
"walletAddressMismatch": "钱包地址与私钥不匹配",
|
||||
"mnemonicLabel": "助记词",
|
||||
"mnemonicPlaceholder": "请输入12或24个单词的助记词(用空格分隔)",
|
||||
"mnemonicRequired": "请输入助记词",
|
||||
"mnemonicInvalid": "助记词格式不正确(应为12或24个单词,用空格分隔)",
|
||||
"walletAddressMismatchMnemonic": "钱包地址与助记词不匹配",
|
||||
"accountName": "账户名称",
|
||||
"accountNameRequired": "请输入账户名称",
|
||||
"accountNamePlaceholder": "请输入账户名称",
|
||||
"accountNamePlaceholder": "可选,用于标识账户",
|
||||
"accountNameHelp": "用于标识账户,便于管理",
|
||||
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
|
||||
"submit": "导入",
|
||||
"invalidPrivateKey": "无效的私钥",
|
||||
"duplicateAccount": "账户已存在",
|
||||
"importAccount": "导入账户",
|
||||
"importSuccess": "导入账户成功",
|
||||
"importFailed": "导入账户失败",
|
||||
"invalidPrivateKey": "无效的私钥",
|
||||
"duplicateAccount": "账户已存在"
|
||||
"derivedAddress": "推导地址",
|
||||
"addressError": "无法从私钥推导地址",
|
||||
"addressErrorMnemonic": "无法从助记词推导地址"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
"leaderName": "Leader 名称",
|
||||
"leaderAddress": "钱包地址",
|
||||
"walletAddress": "钱包地址",
|
||||
"category": "分类",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟单关系数",
|
||||
"createdAt": "创建时间",
|
||||
"action": "操作",
|
||||
"add": "添加",
|
||||
"addLeader": "添加 Leader",
|
||||
"edit": "编辑",
|
||||
"editLeader": "编辑 Leader",
|
||||
"deleteLeader": "删除 Leader"
|
||||
"delete": "删除",
|
||||
"deleteLeader": "删除 Leader",
|
||||
"listFailed": "获取 Leader 列表失败",
|
||||
"deleteSuccess": "删除 Leader 成功",
|
||||
"deleteFailed": "删除 Leader 失败",
|
||||
"deleteConfirm": "确定要删除这个 Leader 吗?",
|
||||
"deleteConfirmDesc": "删除后无法恢复,请谨慎操作!",
|
||||
"deleteConfirmOk": "确定删除"
|
||||
},
|
||||
"menu": {
|
||||
"accounts": "账户管理",
|
||||
@@ -153,6 +237,7 @@
|
||||
"systemSettings": "系统管理",
|
||||
"systemOverview": "概览",
|
||||
"language": "语言",
|
||||
"rpcNodes": "RPC节点管理",
|
||||
"apiHealth": "API健康",
|
||||
"builderApiKey": "Builder API Key",
|
||||
"proxy": "代理",
|
||||
@@ -197,7 +282,9 @@
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失败",
|
||||
"getFailed": "获取代理配置失败",
|
||||
"latency": "延迟"
|
||||
"latency": "延迟",
|
||||
"hostInvalid": "请输入有效的主机地址",
|
||||
"portInvalid": "端口必须在 1-65535 之间"
|
||||
},
|
||||
"languageSettings": {
|
||||
"title": "语言设置",
|
||||
@@ -341,6 +428,7 @@
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
"back": "返回",
|
||||
"leaderAddress": "Leader 钱包地址",
|
||||
"leaderAddressRequired": "请输入 Leader 钱包地址",
|
||||
"leaderAddressInvalid": "钱包地址格式不正确(必须是 0x 开头的 42 位地址)",
|
||||
@@ -582,6 +670,13 @@
|
||||
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
|
||||
"minPricePlaceholder": "最低价(留空不限制)",
|
||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||
"positionLimitFilter": "最大仓位限制",
|
||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxPositionCount": "最大仓位数量",
|
||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"supportSell": "跟单卖出",
|
||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||
"invalidNumber": "请输入有效的数字"
|
||||
@@ -659,6 +754,13 @@
|
||||
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
|
||||
"minPricePlaceholder": "最低价(留空不限制)",
|
||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||
"positionLimitFilter": "最大仓位限制",
|
||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxPositionCount": "最大仓位数量",
|
||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"supportSell": "跟单卖出",
|
||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
|
||||
"create": "创建跟单配置",
|
||||
@@ -667,7 +769,11 @@
|
||||
"invalidNumber": "请输入有效的数字",
|
||||
"fetchLeaderFailed": "获取 Leader 列表失败",
|
||||
"fetchTemplateFailed": "获取模板列表失败",
|
||||
"templateName": "模板名称"
|
||||
"templateName": "模板名称",
|
||||
"noAccounts": "暂无账户",
|
||||
"importAccount": "导入账户",
|
||||
"noLeaders": "暂无 Leader",
|
||||
"addLeader": "添加 Leader"
|
||||
},
|
||||
"copyTradingEdit": {
|
||||
"title": "编辑跟单配置",
|
||||
@@ -734,6 +840,13 @@
|
||||
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
|
||||
"minPricePlaceholder": "最低价(留空不限制)",
|
||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||
"positionLimitFilter": "最大仓位限制",
|
||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxPositionCount": "最大仓位数量",
|
||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"supportSell": "跟单卖出",
|
||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
|
||||
"save": "保存",
|
||||
@@ -764,6 +877,8 @@
|
||||
"orderbookError": "订单簿获取失败",
|
||||
"orderbookEmpty": "订单簿为空",
|
||||
"priceRange": "价格区间不符",
|
||||
"maxPositionValue": "超过最大仓位金额",
|
||||
"maxPositionCount": "超过最大仓位数量",
|
||||
"unknown": "未知原因"
|
||||
},
|
||||
"noData": "暂无已过滤订单"
|
||||
@@ -951,5 +1066,66 @@
|
||||
"averagePnl": "平均盈亏",
|
||||
"maxPnl": "最大盈亏",
|
||||
"minPnl": "最小盈亏"
|
||||
},
|
||||
"rpcNodeSettings": {
|
||||
"title": "Polygon RPC 节点配置",
|
||||
"fetchFailed": "获取节点列表失败",
|
||||
"checkHealthSuccess": "健康检查完成",
|
||||
"checkHealthFailed": "健康检查失败",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"adjustPrioritySuccess": "调整成功",
|
||||
"adjustPriorityFailed": "调整失败",
|
||||
"priority": "优先级",
|
||||
"providerType": "服务商",
|
||||
"name": "名称",
|
||||
"status": "状态",
|
||||
"statusHealthy": "可用",
|
||||
"statusUnhealthy": "不可用",
|
||||
"statusUnknown": "未知",
|
||||
"responseTime": "响应时间",
|
||||
"enabled": "启用",
|
||||
"actions": "操作",
|
||||
"check": "检查",
|
||||
"enableSuccess": "启用成功",
|
||||
"disableSuccess": "禁用成功",
|
||||
"updateFailed": "更新失败",
|
||||
"checkSuccess": "检查完成",
|
||||
"checkFailed": "检查失败",
|
||||
"deleteConfirm": "确定删除此节点吗?",
|
||||
"deleteConfirmOk": "确定",
|
||||
"deleteConfirmCancel": "取消",
|
||||
"delete": "删除",
|
||||
"batchCheck": "批量检查",
|
||||
"addNode": "添加节点",
|
||||
"addNodeTitle": "添加 RPC 节点",
|
||||
"validateAndAdd": "验证并添加",
|
||||
"providerTypeLabel": "服务商类型",
|
||||
"providerTypeRequired": "请选择服务商类型",
|
||||
"nodeNameLabel": "节点名称",
|
||||
"nodeNameRequired": "请输入节点名称",
|
||||
"nodeNamePlaceholder": "例如: My Alchemy Node",
|
||||
"apiKeyLabel": "API Key",
|
||||
"getApiKey": "获取 API Key",
|
||||
"apiKeyRequired": "请输入 API Key",
|
||||
"apiKeyPlaceholder": "输入您的 API Key",
|
||||
"httpUrlLabel": "HTTP RPC URL",
|
||||
"httpUrlRequired": "请输入 HTTP RPC URL",
|
||||
"httpUrlInvalid": "请输入有效的 URL",
|
||||
"httpUrlPlaceholder": "https://polygon-rpc.com",
|
||||
"wsUrlLabel": "WebSocket URL (可选)",
|
||||
"wsUrlInvalid": "请输入有效的 URL",
|
||||
"wsUrlPlaceholder": "wss://polygon-rpc.com",
|
||||
"validateFailed": "节点验证失败:",
|
||||
"addSuccess": "添加成功",
|
||||
"addFailed": "添加失败",
|
||||
"validateError": "验证失败",
|
||||
"operationFailed": "操作失败",
|
||||
"customNode": "自定义节点",
|
||||
"providerAlchemy": "Alchemy",
|
||||
"providerInfura": "Infura",
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"updateSuccess": "更新賬戶成功",
|
||||
"updateFailed": "更新賬戶失敗",
|
||||
"getDetailFailed": "獲取賬戶詳情失敗",
|
||||
"accountIdRequired": "賬戶ID不能為空"
|
||||
"accountIdRequired": "賬戶ID不能為空",
|
||||
"proxyAddress": "代理錢包地址"
|
||||
},
|
||||
"message": {
|
||||
"success": "操作成功",
|
||||
@@ -236,6 +237,7 @@
|
||||
"systemSettings": "系統管理",
|
||||
"systemOverview": "概覽",
|
||||
"language": "語言",
|
||||
"rpcNodes": "RPC節點管理",
|
||||
"apiHealth": "API健康",
|
||||
"builderApiKey": "Builder API Key",
|
||||
"proxy": "代理",
|
||||
@@ -426,6 +428,7 @@
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
"back": "返回",
|
||||
"leaderAddress": "Leader 錢包地址",
|
||||
"leaderAddressRequired": "請輸入 Leader 錢包地址",
|
||||
"leaderAddressInvalid": "錢包地址格式不正確(必須是 0x 開頭的 42 位地址)",
|
||||
@@ -667,6 +670,13 @@
|
||||
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
|
||||
"minPricePlaceholder": "最低價(留空不限制)",
|
||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||
"positionLimitFilter": "最大倉位限制",
|
||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxPositionCount": "最大倉位數量",
|
||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"supportSell": "跟單賣出",
|
||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||
"invalidNumber": "請輸入有效的數字"
|
||||
@@ -732,6 +742,13 @@
|
||||
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
|
||||
"minPricePlaceholder": "最低價(留空不限制)",
|
||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||
"positionLimitFilter": "最大倉位限制",
|
||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxPositionCount": "最大倉位數量",
|
||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"configName": "配置名",
|
||||
"configNameRequired": "請輸入配置名",
|
||||
"configNamePlaceholder": "例如:跟單配置1",
|
||||
@@ -752,7 +769,11 @@
|
||||
"invalidNumber": "請輸入有效的數字",
|
||||
"fetchLeaderFailed": "獲取 Leader 列表失敗",
|
||||
"fetchTemplateFailed": "獲取模板列表失敗",
|
||||
"templateName": "模板名稱"
|
||||
"templateName": "模板名稱",
|
||||
"noAccounts": "暫無賬戶",
|
||||
"importAccount": "導入賬戶",
|
||||
"noLeaders": "暫無 Leader",
|
||||
"addLeader": "添加 Leader"
|
||||
},
|
||||
"copyTradingEdit": {
|
||||
"title": "編輯跟單配置",
|
||||
@@ -807,6 +828,13 @@
|
||||
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
|
||||
"minPricePlaceholder": "最低價(留空不限制)",
|
||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||
"positionLimitFilter": "最大倉位限制",
|
||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxPositionCount": "最大倉位數量",
|
||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"configName": "配置名",
|
||||
"configNameRequired": "請輸入配置名",
|
||||
"configNamePlaceholder": "例如:跟單配置1",
|
||||
@@ -849,6 +877,8 @@
|
||||
"orderbookError": "訂單簿獲取失敗",
|
||||
"orderbookEmpty": "訂單簿為空",
|
||||
"priceRange": "價格區間不符",
|
||||
"maxPositionValue": "超過最大倉位金額",
|
||||
"maxPositionCount": "超過最大倉位數量",
|
||||
"unknown": "未知原因"
|
||||
},
|
||||
"noData": "暫無已過濾訂單"
|
||||
@@ -856,6 +886,8 @@
|
||||
"copyTradingList": {
|
||||
"title": "跟單配置管理",
|
||||
"addCopyTrading": "新增跟單",
|
||||
"configName": "配置名",
|
||||
"configNameNotProvided": "未提供",
|
||||
"wallet": "錢包",
|
||||
"account": "賬戶",
|
||||
"template": "模板",
|
||||
@@ -1034,5 +1066,66 @@
|
||||
"averagePnl": "平均盈虧",
|
||||
"maxPnl": "最大盈虧",
|
||||
"minPnl": "最小盈虧"
|
||||
},
|
||||
"rpcNodeSettings": {
|
||||
"title": "Polygon RPC 節點配置",
|
||||
"fetchFailed": "獲取節點列表失敗",
|
||||
"checkHealthSuccess": "健康檢查完成",
|
||||
"checkHealthFailed": "健康檢查失敗",
|
||||
"deleteSuccess": "刪除成功",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"adjustPrioritySuccess": "調整成功",
|
||||
"adjustPriorityFailed": "調整失敗",
|
||||
"priority": "優先級",
|
||||
"providerType": "服務商",
|
||||
"name": "名稱",
|
||||
"status": "狀態",
|
||||
"statusHealthy": "可用",
|
||||
"statusUnhealthy": "不可用",
|
||||
"statusUnknown": "未知",
|
||||
"responseTime": "響應時間",
|
||||
"enabled": "啟用",
|
||||
"actions": "操作",
|
||||
"check": "檢查",
|
||||
"enableSuccess": "啟用成功",
|
||||
"disableSuccess": "禁用成功",
|
||||
"updateFailed": "更新失敗",
|
||||
"checkSuccess": "檢查完成",
|
||||
"checkFailed": "檢查失敗",
|
||||
"deleteConfirm": "確定刪除此節點嗎?",
|
||||
"deleteConfirmOk": "確定",
|
||||
"deleteConfirmCancel": "取消",
|
||||
"delete": "刪除",
|
||||
"batchCheck": "批量檢查",
|
||||
"addNode": "添加節點",
|
||||
"addNodeTitle": "添加 RPC 節點",
|
||||
"validateAndAdd": "驗證並添加",
|
||||
"providerTypeLabel": "服務商類型",
|
||||
"providerTypeRequired": "請選擇服務商類型",
|
||||
"nodeNameLabel": "節點名稱",
|
||||
"nodeNameRequired": "請輸入節點名稱",
|
||||
"nodeNamePlaceholder": "例如: My Alchemy Node",
|
||||
"apiKeyLabel": "API Key",
|
||||
"getApiKey": "獲取 API Key",
|
||||
"apiKeyRequired": "請輸入 API Key",
|
||||
"apiKeyPlaceholder": "輸入您的 API Key",
|
||||
"httpUrlLabel": "HTTP RPC URL",
|
||||
"httpUrlRequired": "請輸入 HTTP RPC URL",
|
||||
"httpUrlInvalid": "請輸入有效的 URL",
|
||||
"httpUrlPlaceholder": "https://polygon-rpc.com",
|
||||
"wsUrlLabel": "WebSocket URL (可選)",
|
||||
"wsUrlInvalid": "請輸入有效的 URL",
|
||||
"wsUrlPlaceholder": "wss://polygon-rpc.com",
|
||||
"validateFailed": "節點驗證失敗:",
|
||||
"addSuccess": "添加成功",
|
||||
"addFailed": "添加失敗",
|
||||
"validateError": "驗證失敗",
|
||||
"operationFailed": "操作失敗",
|
||||
"customNode": "自定義節點",
|
||||
"providerAlchemy": "Alchemy",
|
||||
"providerInfura": "Infura",
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert } from 'antd'
|
||||
import { Card, Form, Button, Typography } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import {
|
||||
getAddressFromPrivateKey,
|
||||
getAddressFromMnemonic,
|
||||
getPrivateKeyFromMnemonic,
|
||||
isValidWalletAddress,
|
||||
isValidPrivateKey,
|
||||
isValidMnemonic
|
||||
} from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { message } from 'antd'
|
||||
import AccountImportForm from '../components/AccountImportForm'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
|
||||
const AccountImport: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { importAccount, loading } = useAccountStore()
|
||||
const [form] = Form.useForm()
|
||||
const [importType, setImportType] = useState<ImportType>('privateKey')
|
||||
const [derivedAddress, setDerivedAddress] = useState<string>('')
|
||||
const [addressError, setAddressError] = useState<string>('')
|
||||
|
||||
// 当私钥输入时,自动推导地址
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const privateKey = e.target.value.trim()
|
||||
if (!privateKey) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证私钥格式
|
||||
if (!isValidPrivateKey(privateKey)) {
|
||||
setAddressError(t('accountImport.privateKeyInvalid'))
|
||||
setDerivedAddress('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const address = getAddressFromPrivateKey(privateKey)
|
||||
setDerivedAddress(address)
|
||||
setAddressError('')
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressError'))
|
||||
setDerivedAddress('')
|
||||
}
|
||||
}
|
||||
|
||||
// 当助记词输入时,自动推导地址
|
||||
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const mnemonic = e.target.value.trim()
|
||||
if (!mnemonic) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证助记词格式
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
setAddressError(t('accountImport.mnemonicInvalid'))
|
||||
setDerivedAddress('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const address = getAddressFromMnemonic(mnemonic, 0)
|
||||
setDerivedAddress(address)
|
||||
setAddressError('')
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
|
||||
setDerivedAddress('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
let privateKey: string
|
||||
let walletAddress: string
|
||||
|
||||
if (importType === 'privateKey') {
|
||||
// 私钥模式
|
||||
privateKey = values.privateKey
|
||||
walletAddress = values.walletAddress
|
||||
|
||||
// 验证推导的地址和输入的地址是否一致
|
||||
if (derivedAddress && walletAddress !== derivedAddress) {
|
||||
message.error(t('accountImport.walletAddressMismatch'))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 助记词模式
|
||||
if (!values.mnemonic) {
|
||||
message.error(t('accountImport.mnemonicRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 从助记词导出私钥和地址
|
||||
privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0)
|
||||
const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0)
|
||||
|
||||
// 如果用户手动输入了地址,验证是否与推导的地址一致
|
||||
if (values.walletAddress) {
|
||||
if (values.walletAddress !== derivedAddressFromMnemonic) {
|
||||
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
|
||||
message.warning(`${t('accountImport.walletAddressMismatchMnemonic')}: ${derivedAddressFromMnemonic}`)
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
} else {
|
||||
// 地址匹配,使用用户输入的地址
|
||||
walletAddress = values.walletAddress
|
||||
}
|
||||
} else {
|
||||
// 如果用户没有输入地址,使用推导的地址
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
}
|
||||
}
|
||||
|
||||
// 验证钱包地址格式
|
||||
if (!isValidWalletAddress(walletAddress)) {
|
||||
message.error(t('accountImport.walletAddressInvalid'))
|
||||
return
|
||||
}
|
||||
|
||||
await importAccount({
|
||||
privateKey: privateKey,
|
||||
walletAddress: walletAddress,
|
||||
accountName: values.accountName
|
||||
})
|
||||
|
||||
message.success(t('accountImport.importSuccess'))
|
||||
navigate('/accounts')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('accountImport.importFailed'))
|
||||
}
|
||||
const handleSuccess = async () => {
|
||||
message.success(t('accountImport.importSuccess'))
|
||||
navigate('/accounts')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -161,165 +31,13 @@ const AccountImport: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Alert
|
||||
message={t('accountImport.securityTip')}
|
||||
description={t('accountImport.securityTipDesc')}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
|
||||
<Form
|
||||
<AccountImportForm
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item label={t('accountImport.importMethod')}>
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value)
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
form.setFieldsValue({ walletAddress: '' })
|
||||
}}
|
||||
>
|
||||
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
|
||||
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{importType === 'privateKey' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('accountImport.privateKeyLabel')}
|
||||
name="privateKey"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.privateKeyRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidPrivateKey(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.privateKeyInvalid')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder={t('accountImport.privateKeyPlaceholder')}
|
||||
onChange={handlePrivateKeyChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.walletAddress')}
|
||||
name="walletAddress"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.walletAddressRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidWalletAddress(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressInvalid')))
|
||||
}
|
||||
if (derivedAddress && value !== derivedAddress) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressMismatch')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('accountImport.mnemonicLabel')}
|
||||
name="mnemonic"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.mnemonicRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidMnemonic(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.mnemonicInvalid')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={t('accountImport.mnemonicPlaceholder')}
|
||||
onChange={handleMnemonicChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.walletAddress')}
|
||||
name="walletAddress"
|
||||
rules={[
|
||||
{ required: true, message: t('accountImport.walletAddressRequired') },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidWalletAddress(value)) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressInvalid')))
|
||||
}
|
||||
if (derivedAddress && value !== derivedAddress) {
|
||||
return Promise.reject(new Error(t('accountImport.walletAddressMismatchMnemonic')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.accountName')}
|
||||
name="accountName"
|
||||
>
|
||||
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{t('accountImport.importAccount')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/accounts')}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => navigate('/accounts')}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ const ApiHealthStatus: React.FC = () => {
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{item.name}
|
||||
@@ -110,9 +111,28 @@ const ApiHealthStatus: React.FC = () => {
|
||||
)}
|
||||
<Badge
|
||||
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
||||
text={getStatusText(item.status)}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{item.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{item.message && item.message !== '连接成功' && (
|
||||
<div>
|
||||
<Text
|
||||
type={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'secondary' : 'danger'}
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
{item.message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Modal, Table, Select, Divider, Input } from 'antd'
|
||||
import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../types'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import AccountImportForm from '../components/AccountImportForm'
|
||||
import LeaderAddForm from '../components/LeaderAddForm'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
@@ -14,6 +17,7 @@ const { Option } = Select
|
||||
const CopyTradingAdd: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -22,6 +26,14 @@ const CopyTradingAdd: React.FC = () => {
|
||||
const [templateModalVisible, setTemplateModalVisible] = useState(false)
|
||||
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
||||
|
||||
// 导入账户modal相关状态
|
||||
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
||||
const [accountImportForm] = Form.useForm()
|
||||
|
||||
// 添加leader modal相关状态
|
||||
const [leaderAddModalVisible, setLeaderAddModalVisible] = useState(false)
|
||||
const [leaderAddForm] = Form.useForm()
|
||||
|
||||
// 生成默认配置名
|
||||
const generateDefaultConfigName = (): string => {
|
||||
const now = new Date()
|
||||
@@ -85,7 +97,9 @@ const CopyTradingAdd: React.FC = () => {
|
||||
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
|
||||
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
|
||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined
|
||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
||||
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
|
||||
maxPositionCount: (template as any).maxPositionCount
|
||||
})
|
||||
setCopyMode(template.copyMode)
|
||||
setTemplateModalVisible(false)
|
||||
@@ -96,6 +110,36 @@ const CopyTradingAdd: React.FC = () => {
|
||||
setCopyMode(mode)
|
||||
}
|
||||
|
||||
// 处理导入账户成功
|
||||
const handleAccountImportSuccess = async (accountId: number) => {
|
||||
message.success(t('accountImport.importSuccess'))
|
||||
|
||||
// 刷新账户列表
|
||||
await fetchAccounts()
|
||||
|
||||
// 自动选择新添加的账户
|
||||
form.setFieldsValue({ accountId })
|
||||
|
||||
// 关闭modal并重置表单
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}
|
||||
|
||||
// 处理添加leader成功
|
||||
const handleLeaderAddSuccess = async (leaderId: number) => {
|
||||
message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功')
|
||||
|
||||
// 刷新leader列表
|
||||
await fetchLeaders()
|
||||
|
||||
// 自动选择新添加的leader
|
||||
form.setFieldsValue({ leaderId })
|
||||
|
||||
// 关闭modal并重置表单
|
||||
setLeaderAddModalVisible(false)
|
||||
leaderAddForm.resetFields()
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
// 前端校验
|
||||
if (values.copyMode === 'FIXED') {
|
||||
@@ -134,6 +178,8 @@ const CopyTradingAdd: React.FC = () => {
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
maxPositionValue: values.maxPositionValue?.toString(),
|
||||
maxPositionCount: values.maxPositionCount,
|
||||
configName: values.configName?.trim(),
|
||||
pushFailedOrders: values.pushFailedOrders ?? false
|
||||
}
|
||||
@@ -209,7 +255,24 @@ const CopyTradingAdd: React.FC = () => {
|
||||
name="accountId"
|
||||
rules={[{ required: true, message: t('copyTradingAdd.walletRequired') || '请选择钱包' }]}
|
||||
>
|
||||
<Select placeholder={t('copyTradingAdd.selectWalletPlaceholder') || '请选择钱包'}>
|
||||
<Select
|
||||
placeholder={t('copyTradingAdd.selectWalletPlaceholder') || '请选择钱包'}
|
||||
notFoundContent={
|
||||
accounts.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px' }}>
|
||||
<div style={{ marginBottom: '8px' }}>{t('copyTradingAdd.noAccounts') || '暂无账户'}</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setAccountImportModalVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('copyTradingAdd.importAccount') || '导入账户'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{accounts.map(account => (
|
||||
<Option key={account.id} value={account.id}>
|
||||
{account.accountName || `账户 ${account.id}`} ({account.walletAddress.slice(0, 6)}...{account.walletAddress.slice(-4)})
|
||||
@@ -223,7 +286,24 @@ const CopyTradingAdd: React.FC = () => {
|
||||
name="leaderId"
|
||||
rules={[{ required: true, message: t('copyTradingAdd.leaderRequired') || '请选择 Leader' }]}
|
||||
>
|
||||
<Select placeholder={t('copyTradingAdd.selectLeaderPlaceholder') || '请选择 Leader'}>
|
||||
<Select
|
||||
placeholder={t('copyTradingAdd.selectLeaderPlaceholder') || '请选择 Leader'}
|
||||
notFoundContent={
|
||||
leaders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px' }}>
|
||||
<div style={{ marginBottom: '8px' }}>{t('copyTradingAdd.noLeaders') || '暂无 Leader'}</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setLeaderAddModalVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('copyTradingAdd.addLeader') || '添加 Leader'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{leaders.map(leader => (
|
||||
<Option key={leader.id} value={leader.id}>
|
||||
{leader.leaderName || `Leader ${leader.id}`} ({leader.leaderAddress.slice(0, 6)}...{leader.leaderAddress.slice(-4)})
|
||||
@@ -467,6 +547,35 @@ const CopyTradingAdd: React.FC = () => {
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingAdd.positionLimitFilter') || '最大仓位限制'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.maxPositionValue') || '最大仓位金额 (USDC)'}
|
||||
name="maxPositionValue"
|
||||
tooltip={t('copyTradingAdd.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.maxPositionCount') || '最大仓位数量'}
|
||||
name="maxPositionCount"
|
||||
tooltip={t('copyTradingAdd.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
|
||||
|
||||
{/* 跟单卖出 */}
|
||||
@@ -550,6 +659,61 @@ const CopyTradingAdd: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 导入账户 Modal */}
|
||||
<Modal
|
||||
title={t('accountImport.title') || '导入账户'}
|
||||
open={accountImportModalVisible}
|
||||
onCancel={() => {
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
>
|
||||
<AccountImportForm
|
||||
form={accountImportForm}
|
||||
onSuccess={handleAccountImportSuccess}
|
||||
onCancel={() => {
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 添加 Leader Modal */}
|
||||
<Modal
|
||||
title={t('leaderAdd.title') || '添加 Leader'}
|
||||
open={leaderAddModalVisible}
|
||||
onCancel={() => {
|
||||
setLeaderAddModalVisible(false)
|
||||
leaderAddForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
>
|
||||
<LeaderAddForm
|
||||
form={leaderAddForm}
|
||||
onSuccess={handleLeaderAddSuccess}
|
||||
onCancel={() => {
|
||||
setLeaderAddModalVisible(false)
|
||||
leaderAddForm.resetFields()
|
||||
}}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ const CopyTradingEdit: React.FC = () => {
|
||||
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
|
||||
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
||||
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
|
||||
maxPositionCount: found.maxPositionCount,
|
||||
configName: found.configName || '',
|
||||
pushFailedOrders: found.pushFailedOrders ?? false
|
||||
})
|
||||
@@ -123,6 +125,8 @@ const CopyTradingEdit: React.FC = () => {
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
maxPositionValue: values.maxPositionValue?.toString(),
|
||||
maxPositionCount: values.maxPositionCount,
|
||||
configName: values.configName?.trim() || undefined,
|
||||
pushFailedOrders: values.pushFailedOrders
|
||||
}
|
||||
@@ -436,6 +440,35 @@ const CopyTradingEdit: React.FC = () => {
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'}
|
||||
name="maxPositionValue"
|
||||
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxPositionCount') || '最大仓位数量'}
|
||||
name="maxPositionCount"
|
||||
tooltip={t('copyTradingEdit.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
|
||||
|
||||
{/* 跟单卖出 */}
|
||||
|
||||
@@ -65,6 +65,8 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
|
||||
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
||||
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
|
||||
maxPositionCount: found.maxPositionCount,
|
||||
configName: found.configName || '',
|
||||
pushFailedOrders: found.pushFailedOrders ?? false
|
||||
})
|
||||
@@ -129,6 +131,8 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
maxSpread: values.maxSpread?.toString(),
|
||||
minPrice: values.minPrice?.toString(),
|
||||
maxPrice: values.maxPrice?.toString(),
|
||||
maxPositionValue: values.maxPositionValue?.toString(),
|
||||
maxPositionCount: values.maxPositionCount,
|
||||
configName: values.configName?.trim() || undefined,
|
||||
pushFailedOrders: values.pushFailedOrders
|
||||
}
|
||||
@@ -436,6 +440,35 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'}
|
||||
name="maxPositionValue"
|
||||
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxPositionCount') || '最大仓位数量'}
|
||||
name="maxPositionCount"
|
||||
tooltip={t('copyTradingEdit.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -1,44 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, Select, message, Typography, Space } from 'antd'
|
||||
import { Card, Form, Button, Typography } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isValidWalletAddress } from '../utils'
|
||||
import { message } from 'antd'
|
||||
import LeaderAddForm from '../components/LeaderAddForm'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const LeaderAdd: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaders.add({
|
||||
leaderAddress: values.leaderAddress.trim(),
|
||||
leaderName: values.leaderName?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
website: values.website?.trim() || undefined,
|
||||
category: values.category || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功')
|
||||
navigate('/leaders')
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderAdd.addFailed') || '添加 Leader 失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const handleSuccess = async () => {
|
||||
message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功')
|
||||
navigate('/leaders')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,105 +25,18 @@ const LeaderAdd: React.FC = () => {
|
||||
onClick={() => navigate('/leaders')}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
返回
|
||||
{t('leaderAdd.back') || '返回'}
|
||||
</Button>
|
||||
<Title level={2} style={{ margin: 0 }}>{t('leaderAdd.title') || '添加 Leader'}</Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
<LeaderAddForm
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
initialValues={{
|
||||
enabled: true
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('leaderAdd.leaderAddress') || 'Leader 钱包地址'}
|
||||
name="leaderAddress"
|
||||
rules={[
|
||||
{ required: true, message: t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址'))
|
||||
}
|
||||
if (!isValidWalletAddress(value.trim())) {
|
||||
return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'}
|
||||
>
|
||||
<Input placeholder="0x..." style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.leaderName') || 'Leader 名称'}
|
||||
name="leaderName"
|
||||
tooltip={t('leaderAdd.leaderNameTooltip') || '可选,用于标识 Leader,方便管理'}
|
||||
>
|
||||
<Input placeholder={t('leaderAdd.leaderNamePlaceholder') || '可选,用于标识 Leader'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.remark') || 'Leader 备注'}
|
||||
name="remark"
|
||||
tooltip={t('leaderAdd.remarkTooltip') || '可选,用于记录 Leader 的备注信息'}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('leaderAdd.remarkPlaceholder') || '可选,用于记录 Leader 的备注信息'}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.website') || 'Leader 网站'}
|
||||
name="website"
|
||||
tooltip={t('leaderAdd.websiteTooltip') || '可选,Leader 的网站链接'}
|
||||
rules={[
|
||||
{
|
||||
type: 'url',
|
||||
message: t('leaderAdd.websiteInvalid') || '请输入有效的 URL 地址'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderAdd.category') || '分类筛选'}
|
||||
name="category"
|
||||
tooltip={t('leaderAdd.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)'}
|
||||
>
|
||||
<Select placeholder={t('leaderAdd.categoryPlaceholder') || '选择分类(可选)'} allowClear>
|
||||
<Option value="sports">Sports</Option>
|
||||
<Option value="crypto">Crypto</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{t('leaderAdd.add') || '添加 Leader'}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/leaders')}>
|
||||
{t('leaderAdd.cancel') || '取消'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => navigate('/leaders')}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } from 'antd'
|
||||
import { Card, Form, Input, Button, message, Typography, Space, Spin } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { Leader } from '../types'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const LeaderEdit: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -38,8 +37,7 @@ const LeaderEdit: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
leaderName: leader.leaderName || '',
|
||||
remark: leader.remark || '',
|
||||
website: leader.website || '',
|
||||
category: leader.category || undefined
|
||||
website: leader.website || ''
|
||||
})
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
|
||||
@@ -65,8 +63,7 @@ const LeaderEdit: React.FC = () => {
|
||||
leaderId: parseInt(leaderId),
|
||||
leaderName: values.leaderName?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
website: values.website?.trim() || undefined,
|
||||
category: values.category || undefined
|
||||
website: values.website?.trim() || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
@@ -145,17 +142,6 @@ const LeaderEdit: React.FC = () => {
|
||||
<Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('leaderEdit.category') || '分类筛选'}
|
||||
name="category"
|
||||
tooltip={t('leaderEdit.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)'}
|
||||
>
|
||||
<Select placeholder={t('leaderEdit.categoryPlaceholder') || '选择分类(可选)'} allowClear>
|
||||
<Option value="sports">Sports</Option>
|
||||
<Option value="crypto">Crypto</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Leader } from '../types'
|
||||
@@ -67,14 +67,6 @@ const LeaderList: React.FC = () => {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderList.category') || '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string | undefined) => category ? (
|
||||
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
|
||||
) : <Tag>{t('leaderList.all') || '全部'}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('leaderList.remark') || '备注',
|
||||
dataIndex: 'remark',
|
||||
@@ -233,37 +225,11 @@ const LeaderList: React.FC = () => {
|
||||
</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' }} />
|
||||
|
||||
{/* 分类和跟单关系数 */}
|
||||
{/* 跟单关系数 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
{leader.category ? (
|
||||
<Tag color={leader.category === 'sports' ? 'blue' : 'green'}>
|
||||
{leader.category}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>{t('leaderList.all') || '全部'}</Tag>
|
||||
)}
|
||||
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
|
||||
</div>
|
||||
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
|
||||
</div>
|
||||
|
||||
{/* 创建时间 */}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Table, Button, Space, Badge, message, Popconfirm, Tag, Switch } from 'antd'
|
||||
import { UpOutlined, DownOutlined, DeleteOutlined, ReloadOutlined, PlusOutlined, ApiOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { RpcNodeConfig } from '../types'
|
||||
import AddRpcNodeModal from '../components/AddRpcNodeModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RpcNodeSettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [nodes, setNodes] = useState<RpcNodeConfig[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes()
|
||||
}, [])
|
||||
|
||||
const fetchNodes = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.rpcNodes.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setNodes(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.fetchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckAllHealth = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
const response = await apiService.rpcNodes.checkHealth({})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.checkHealthSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.checkHealthFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.checkHealthFailed'))
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.delete({ id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.deleteSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.deleteFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovePriority = async (id: number, direction: 'up' | 'down') => {
|
||||
const index = nodes.findIndex(n => n.id === id)
|
||||
if (index === -1) return
|
||||
|
||||
if (direction === 'up' && index === 0) return
|
||||
if (direction === 'down' && index === nodes.length - 1) return
|
||||
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||
const newPriority = nodes[targetIndex].priority
|
||||
|
||||
try {
|
||||
const response = await apiService.rpcNodes.updatePriority({ id, priority: newPriority })
|
||||
if (response.data.code === 0) {
|
||||
// 同时更新另一个节点的优先级
|
||||
await apiService.rpcNodes.updatePriority({
|
||||
id: nodes[targetIndex].id,
|
||||
priority: nodes[index].priority
|
||||
})
|
||||
message.success(t('rpcNodeSettings.adjustPrioritySuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.adjustPriorityFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.adjustPriorityFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (id: number, enabled: boolean) => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.update({ id, enabled })
|
||||
if (response.data.code === 0) {
|
||||
message.success(enabled ? t('rpcNodeSettings.enableSuccess') : t('rpcNodeSettings.disableSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.updateFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('rpcNodeSettings.priority'),
|
||||
dataIndex: 'priority',
|
||||
width: 120,
|
||||
render: (_: any, record: RpcNodeConfig, index: number) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
onClick={() => handleMovePriority(record.id, 'up')}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
onClick={() => handleMovePriority(record.id, 'down')}
|
||||
disabled={index === nodes.length - 1}
|
||||
/>
|
||||
<span>{index + 1}</span>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.providerType'),
|
||||
dataIndex: 'providerType',
|
||||
width: 120,
|
||||
render: (type: string) => <Tag color="blue">{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.enabled'),
|
||||
dataIndex: 'enabled',
|
||||
width: 100,
|
||||
render: (enabled: boolean, record: RpcNodeConfig) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => handleToggleEnabled(record.id, checked)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.status'),
|
||||
dataIndex: 'lastCheckStatus',
|
||||
width: 100,
|
||||
render: (status: string | undefined) => {
|
||||
const statusMap = {
|
||||
HEALTHY: { status: 'success' as const, text: t('rpcNodeSettings.statusHealthy') },
|
||||
UNHEALTHY: { status: 'error' as const, text: t('rpcNodeSettings.statusUnhealthy') },
|
||||
UNKNOWN: { status: 'default' as const, text: t('rpcNodeSettings.statusUnknown') }
|
||||
}
|
||||
const config = statusMap[status as keyof typeof statusMap] || statusMap.UNKNOWN
|
||||
return <Badge status={config.status} text={config.text} />
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.responseTime'),
|
||||
dataIndex: 'responseTimeMs',
|
||||
width: 100,
|
||||
render: (time: number | undefined) => time ? `${time}ms` : '-'
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.actions'),
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: any, record: RpcNodeConfig) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.checkHealth({ id: record.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.checkSuccess'))
|
||||
fetchNodes()
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(t('rpcNodeSettings.checkFailed'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('rpcNodeSettings.check')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('rpcNodeSettings.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('rpcNodeSettings.deleteConfirmOk')}
|
||||
cancelText={t('rpcNodeSettings.deleteConfirmCancel')}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('rpcNodeSettings.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ApiOutlined />
|
||||
<span>{t('rpcNodeSettings.title')}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleCheckAllHealth}
|
||||
loading={checking}
|
||||
>
|
||||
{t('rpcNodeSettings.batchCheck')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
{t('rpcNodeSettings.addNode')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={nodes}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<AddRpcNodeModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onSuccess={() => {
|
||||
setModalVisible(false)
|
||||
fetchNodes()
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default RpcNodeSettings
|
||||
@@ -348,10 +348,11 @@ const SystemSettings: React.FC = () => {
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const config = response.data.data
|
||||
setSystemConfig(config)
|
||||
// 将已配置的值填充到输入框中
|
||||
relayerForm.setFieldsValue({
|
||||
builderApiKey: '',
|
||||
builderSecret: '',
|
||||
builderPassphrase: '',
|
||||
builderApiKey: config.builderApiKeyDisplay || '',
|
||||
builderSecret: config.builderSecretDisplay || '',
|
||||
builderPassphrase: config.builderPassphraseDisplay || '',
|
||||
})
|
||||
autoRedeemForm.setFieldsValue({
|
||||
autoRedeemEnabled: config.autoRedeemEnabled
|
||||
@@ -685,30 +686,32 @@ const SystemSettings: React.FC = () => {
|
||||
<Form.Item
|
||||
label={t('builderApiKey.apiKey')}
|
||||
name="builderApiKey"
|
||||
help={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
|
||||
>
|
||||
<Input
|
||||
placeholder={systemConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
|
||||
placeholder={t('builderApiKey.apiKeyPlaceholder')}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('builderApiKey.secret')}
|
||||
name="builderSecret"
|
||||
help={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
|
||||
>
|
||||
<Input
|
||||
placeholder={systemConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
|
||||
<Input.Password
|
||||
placeholder={t('builderApiKey.secretPlaceholder')}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
iconRender={(visible) => (visible ? <span>👁️</span> : <span>👁️🗨️</span>)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('builderApiKey.passphrase')}
|
||||
name="builderPassphrase"
|
||||
help={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
|
||||
>
|
||||
<Input
|
||||
placeholder={systemConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
|
||||
<Input.Password
|
||||
placeholder={t('builderApiKey.passphrasePlaceholder')}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
iconRender={(visible) => (visible ? <span>👁️</span> : <span>👁️🗨️</span>)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -884,6 +887,7 @@ const SystemSettings: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -618,6 +618,33 @@ export const apiService = {
|
||||
/**
|
||||
* 公告 API
|
||||
*/
|
||||
/**
|
||||
* RPC 节点配置 API
|
||||
*/
|
||||
rpcNodes: {
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<import('../types').RpcNodeConfig[]>>('/system/rpc-nodes/list', {}),
|
||||
|
||||
add: (data: import('../types').RpcNodeAddRequest) =>
|
||||
apiClient.post<ApiResponse<import('../types').RpcNodeConfig>>('/system/rpc-nodes/add', data),
|
||||
|
||||
update: (data: import('../types').RpcNodeUpdateRequest) =>
|
||||
apiClient.post<ApiResponse<import('../types').RpcNodeConfig>>('/system/rpc-nodes/update', data),
|
||||
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/system/rpc-nodes/delete', data),
|
||||
|
||||
updatePriority: (data: { id: number; priority: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/system/rpc-nodes/update-priority', data),
|
||||
|
||||
checkHealth: (data: { id?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/system/rpc-nodes/check-health', data),
|
||||
|
||||
validate: (data: import('../types').RpcNodeAddRequest) =>
|
||||
apiClient.post<ApiResponse<{ valid: boolean; message: string; responseTimeMs?: number }>>('/system/rpc-nodes/validate', data)
|
||||
},
|
||||
|
||||
|
||||
announcements: {
|
||||
/**
|
||||
* 获取公告列表(最近10条)
|
||||
|
||||
@@ -205,6 +205,9 @@ export interface CopyTrading {
|
||||
maxSpread?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
||||
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
configName?: string // 配置名(可选,但提供时必须非空)
|
||||
pushFailedOrders: boolean // 推送失败订单(默认关闭)
|
||||
@@ -248,6 +251,9 @@ export interface CopyTradingCreateRequest {
|
||||
maxSpread?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
||||
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
configName?: string // 配置名(可选,但提供时必须非空)
|
||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||
@@ -279,6 +285,9 @@ export interface CopyTradingUpdateRequest {
|
||||
maxSpread?: string
|
||||
minPrice?: string // 最低价格(可选),NULL表示不限制最低价
|
||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
||||
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
configName?: string // 配置名(可选,但提供时必须非空)
|
||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||
@@ -770,6 +779,9 @@ export interface SystemConfig {
|
||||
builderApiKeyConfigured: boolean
|
||||
builderSecretConfigured: boolean
|
||||
builderPassphraseConfigured: boolean
|
||||
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示)
|
||||
builderSecretDisplay?: string // Builder Secret 显示值(部分显示)
|
||||
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示)
|
||||
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
|
||||
}
|
||||
|
||||
@@ -794,3 +806,54 @@ export interface NotificationConfigUpdateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* RPC 节点配置类型
|
||||
*/
|
||||
export interface RpcNodeConfig {
|
||||
id: number
|
||||
providerType: 'ALCHEMY' | 'INFURA' | 'QUICKNODE' | 'CHAINSTACK' | 'GETBLOCK' | 'CUSTOM' | 'PUBLIC'
|
||||
name: string
|
||||
httpUrl: string
|
||||
wsUrl?: string
|
||||
apiKeyMasked?: string // 脱敏后的 API Key
|
||||
enabled: boolean
|
||||
priority: number
|
||||
lastCheckTime?: number
|
||||
lastCheckStatus?: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN'
|
||||
responseTimeMs?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 RPC 节点请求
|
||||
*/
|
||||
export interface RpcNodeAddRequest {
|
||||
providerType: string
|
||||
name: string
|
||||
apiKey?: string // 主流服务商需要
|
||||
httpUrl?: string // CUSTOM 需要
|
||||
wsUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 RPC 节点请求
|
||||
*/
|
||||
export interface RpcNodeUpdateRequest {
|
||||
id: number
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点健康检查结果
|
||||
*/
|
||||
export interface NodeCheckResult {
|
||||
status: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN'
|
||||
message: string
|
||||
checkTime: number
|
||||
responseTimeMs?: number
|
||||
blockNumber?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user