Compare commits

...

12 Commits

Author SHA1 Message Date
WrBug 5e544cda07 fix: 修复固定金额模式卖出数量计算逻辑
- 删除卖出订单的 notificationSent 字段,使用 priceUpdated 作为共用字段
- 添加 leaderBuyQuantity 字段,在创建 CopyOrderTracking 时存储 Leader 买入数量
- 修复固定金额模式下卖出数量计算:优先使用存储的 leaderBuyQuantity,避免 API 查询失败
- 添加数据库迁移 V14 和 V15
- 优化 calculateSellQuantityForFixedMode 方法,支持新数据和旧数据兼容
2025-12-26 05:42:53 +08:00
WrBug b6d66aa81e docs: 添加 Docker 版本更新说明(中英文)
- 在部署文档中添加详细的 Docker 版本更新步骤(中文和英文)
- 在 README 中添加简要的更新说明和文档链接(中文和英文)
- 包含更新到最新版本和特定版本的方法
- 添加注意事项(备份数据库、服务中断等)
2025-12-26 02:30:06 +08:00
WrBug 62669719d9 feat: 实现卖出订单价格轮询更新和订单状态管理
- 添加 price_updated 字段到 sell_match_record 表,用于标记价格是否已更新
- 创建 OrderStatusUpdateService 定时任务服务,每5秒轮询一次:
  - 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
  - 清理已删除账户的订单记录
- 修改 processSellTrade:下单完成后不再立即查询价格,直接保存,等待定时任务更新
- 添加 orderId 格式验证:非0x开头的直接标记为已更新,0x开头的等待定时任务更新
- 启用 Spring 定时任务功能(@EnableScheduling)
- 将 getActualExecutionPrice 方法改为 public,供定时任务调用
2025-12-26 02:20:48 +08:00
WrBug a6ca6cc3ff feat: 支持 RPC 节点启用/禁用功能
- 后端:更新 RpcNodeService,确保禁用的节点被忽略
  - getAllNodesWithDefault() 只返回启用的节点
  - checkAllNodesHealth() 只检查启用的节点
  - getAvailableNode() 已正确实现,只查询启用的节点

- 前端:添加启用/禁用开关功能
  - 在 RpcNodeSettings 页面添加启用列,使用 Switch 组件
  - 添加 handleToggleEnabled 函数处理启用/禁用切换
  - 添加多语言支持(中文简体、繁体、英文)
2025-12-26 01:57:22 +08:00
WrBug d1f351108f fix: 修复跟单卖出订单的 API 凭证解密和精度问题
- 修复 processSellTrade 中 API 凭证未解密的问题,与 processBuyTrade 保持一致
- 修复 SELL 订单的 makerAmount 和 takerAmount 精度问题:
  - makerAmount (shares) 最多 2 位小数(符合 API 要求)
  - takerAmount (USDC) 最多 4 位小数(符合 API 要求)
2025-12-26 01:50:03 +08:00
WrBug c4ac3a8fe0 feat: 实现链上 WebSocket 监听和并行监控策略
- 创建 OnChainWsService,支持通过 Polygon RPC eth_subscribe 实时监听链上交易
- 实现并行监控策略:链上 WS 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 eth_unsubscribe 取消单个 Leader 的订阅
- 在 CopyOrderTrackingService 中添加 Mutex 保证线程安全
- 实现链上交易解析逻辑(USDC Transfer + ERC1155 Transfer)
- 实现 Gamma API 元数据补齐逻辑
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,JsonRpcResponse.result 使用 JsonElement 类型
- 添加相关文档:copy-trading-logic-summary.md 和 copy-trading-monitor-strategy.md
2025-12-26 01:36:00 +08:00
WrBug 1580b60d24 feat: 实现RPC节点管理功能
- 后端功能:
  - 添加RPC节点配置管理(增删改查、优先级调整)
  - 实现节点健康检查和自动故障转移
  - 修复Retrofit baseUrl处理问题(使用拦截器动态替换URL)
  - 添加RPC节点可用性验证(创建前验证)
  - 默认节点作为兜底,不返回给前端,始终排在最后
  - API健康检查使用动态获取的可用节点

- 前端功能:
  - 添加RPC节点设置页面
  - 支持添加、删除、检查节点
  - 支持调整节点优先级
  - 完整的多语言支持(中文简体/繁体、英文)
  - 添加菜单项多语言key

- 数据库:
  - 添加RPC节点配置表迁移脚本
2025-12-25 23:58:50 +08:00
WrBug 7bc059ec34 fix: 修复前端编译错误
- 移除 AccountImportForm 中未使用的 message 导入
- 移除 AccountImport 中 handleSuccess 未使用的 accountId 参数
2025-12-23 01:08:54 +08:00
WrBug c20e0138df feat: 添加最大仓位限制配置功能
后端变更:
- 添加最大仓位金额(maxPositionValue)和最大仓位数量(maxPositionCount)配置字段
- 实现按市场检查仓位限制的过滤逻辑
- 添加数据库迁移脚本 V11__add_max_position_config.sql

前端变更:
- 重构账户导入和Leader添加为可复用组件(AccountImportForm, LeaderAddForm)
- 在CopyTradingAdd页面集成账户导入和Leader添加Modal
- 在CopyTradingAdd/CopyTradingEdit页面添加最大仓位限制配置项
- 补充所有多语言文件中的缺失键,确保zh-CN/zh-TW/en三种语言文件一致

功能说明:
- 支持设置单个市场的最大仓位金额限制(USDC)
- 支持设置单个市场的最大仓位数量限制
- 两个限制可以同时启用,下单前会检查当前市场仓位是否超过限制
2025-12-23 00:56:04 +08:00
WrBug 20acc72ed6 Merge pull request #4 from WrBug/pre_release
v1.0.2: 优化系统配置和统计功能
2025-12-11 03:20:50 +08:00
WrBug a31ed31c98 feat: 优化系统配置和统计功能
- 系统配置支持显示完整的 Builder API Key(不再部分显示)
- Secret 和 Passphrase 使用密码输入框,支持眼睛图标切换显示/隐藏
- 移除已配置提示,简化用户界面
- 修复总卖出金额统计:使用 SellMatchDetail 计算,确保准确性
- 修复 matchSellOrder 中价格不一致问题:matchDetails 使用实际卖出价格
- 移动端 Leader 列表移除网站显示行
- 优化订单创建前订单簿匹配检查,避免 FAK 订单失败
2025-12-11 03:17:41 +08:00
WrBug 0625c62e36 feat: 优化跟单买入逻辑和错误处理
- 更新 Twitter 链接为 @polyhermes
- 简化订单创建失败日志,只保留 code 和 errorBody
- 修复 @Transactional 注解问题(类改为 open class)
- 改进唯一键冲突异常处理,支持并发场景
- 整理跟单买入和重试逻辑,使用常量配置
- 重试延迟从 1 秒调整为 3 秒
- 添加订单簿匹配检查,避免 FAK 订单失败
- 前端移除 Leader 管理的分类相关设置和显示
- 补充多语言翻译(AccountImport 和 CopyTradingList)
2025-12-11 02:58:50 +08:00
63 changed files with 6896 additions and 914 deletions
+1
View File
@@ -109,4 +109,5 @@ __pycache__/
# Submodules and external dependencies
clob-client/
builder-relayer-client/
landing-page/
+1 -1
View File
@@ -122,7 +122,7 @@ docker pull wrbug/polyhermes:v1.0.1
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@quant_tr
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
+288
View File
@@ -0,0 +1,288 @@
# 🎉 v1.1.0 版本更新指南
## 📅 发布日期
2025年12月26日
## 🎯 这次更新对您意味着什么?
### ⚡ 更快的跟单响应速度
- **实时链上监听**:通过 WebSocket 实时监听链上交易,跟单响应速度提升 50% 以上
- **双重保障**:链上监听和轮询同时运行,确保不错过任何交易机会
- **自动去重**:系统自动处理重复数据,确保每笔交易只执行一次
### 💰 更准确的盈亏统计
- **实际成交价追踪**:系统会自动更新卖出订单的实际成交价,而不是下单时的价格
- **自动价格更新**:每 5 秒自动查询并更新订单的实际成交价
- **精确盈亏计算**:基于实际成交价计算盈亏,统计数据更准确
### 🔧 更灵活的系统配置
- **RPC 节点管理**:可以添加、编辑、删除自定义 RPC 节点
- **节点启用/禁用**:可以随时启用或禁用节点,无需删除配置
- **智能节点选择**:系统自动选择可用的节点,禁用的节点会被忽略
### 🐛 问题修复
- **修复卖出订单失败问题**:修复了部分情况下卖出订单因 API 凭证问题导致的失败
- **修复订单精度错误**:修复了卖出订单因精度问题导致的 API 错误
## 📦 如何更新
### 方式一:使用 Docker(推荐)
#### 步骤 1:备份数据(强烈推荐)
**备份不是必须的,但强烈推荐!**
**为什么需要备份?**
- Docker 更新不会删除数据(数据存储在独立的数据卷中)
- 但数据库结构可能会变更(本次更新会添加 `price_updated` 字段)
- 如果迁移失败或出现问题,备份可以帮助恢复数据
- 生产环境建议必须备份,开发环境可以跳过
**如何备份?**
```bash
# 方式 1:使用 mysqldump 备份数据库(推荐)
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql
# 方式 2:备份整个 MySQL 数据卷
docker run --rm -v polyhermes_mysql-data:/data -v $(pwd):/backup alpine tar czf /backup/mysql_backup_$(date +%Y%m%d_%H%M%S).tar.gz /data
# 方式 3:如果已有定期备份,可以跳过此步骤
```
**什么情况下可以跳过备份?**
- ✅ 开发环境或测试环境
- ✅ 数据不重要或可以重新生成
- ✅ 已经有定期自动备份
- ✅ 确定迁移不会失败(查看更新日志确认)
**什么情况下必须备份?**
- ⚠️ 生产环境
- ⚠️ 包含重要交易数据
- ⚠️ 不确定迁移是否安全
#### 步骤 2:停止当前服务
```bash
# 进入部署目录
cd /path/to/polyhermes
# 停止服务
docker-compose -f docker-compose.prod.yml down
```
#### 步骤 3:拉取新版本
```bash
# 拉取 v1.1.0 版本
docker pull wrbug/polyhermes:v1.1.0
# 或者拉取最新版本(推荐)
docker pull wrbug/polyhermes:latest
```
#### 步骤 4:更新配置(如果需要)
如果您想固定使用 v1.1.0 版本,可以编辑 `docker-compose.prod.yml`
```yaml
services:
app:
image: wrbug/polyhermes:v1.1.0 # 修改这里
```
#### 步骤 5:启动服务
```bash
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 查看日志,确认服务正常启动
docker-compose -f docker-compose.prod.yml logs -f
```
#### 步骤 6:验证更新
1. 访问系统首页,查看页面标题是否显示 `v1.1.0`
2. 检查系统功能是否正常
3. 查看日志是否有错误信息
### 方式二:一键更新脚本
如果您使用 `latest` 标签,可以使用以下命令一键更新:
```bash
# 进入部署目录
cd /path/to/polyhermes
# 拉取最新镜像并重启
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f
```
## ⚠️ 更新注意事项
### 1. 数据库迁移
- 本次更新会自动执行数据库迁移(添加 `price_updated` 字段)
- 迁移过程不会影响现有数据,只是添加新字段
- 迁移是安全的,但如果迁移失败,请检查数据库权限和连接
- **如果迁移失败,可以使用备份恢复数据**
### 2. 服务中断时间
- 更新过程中服务会短暂中断(通常 1-2 分钟)
- 建议在低峰期进行更新
- 更新期间正在进行的跟单操作可能会受影响
### 3. 配置检查
- 更新后请检查 RPC 节点配置是否正确
- 如果使用自定义 RPC 节点,请确认节点状态正常
### 4. 功能验证
更新后建议验证以下功能:
- ✅ 跟单功能是否正常
- ✅ 订单价格更新是否正常
- ✅ RPC 节点管理是否正常
- ✅ 盈亏统计是否准确
## 🆕 新功能使用指南
### 1. RPC 节点管理
#### 如何添加自定义 RPC 节点?
1. 登录系统,进入 **系统设置****RPC 节点设置**
2. 点击 **添加节点** 按钮
3. 填写节点信息:
- **节点名称**:给节点起个名字(如:Alchemy Polygon
- **RPC URL**:节点的 HTTP/HTTPS 地址
- **启用状态**:默认启用,可以稍后禁用
4. 点击 **保存**
#### 如何启用/禁用节点?
1. 在 RPC 节点列表中,找到要操作的节点
2. 点击 **启用/禁用** 开关
3. 系统会自动更新节点状态
**提示**
- 禁用的节点不会被使用,但配置会保留
- 可以随时重新启用禁用的节点
- 建议至少保留一个启用的节点
### 2. 实际成交价追踪
#### 这个功能做什么?
系统会自动追踪卖出订单的实际成交价,而不是使用下单时的价格。这样可以:
- 更准确地计算盈亏
- 了解订单的实际执行情况
- 优化卖出策略
#### 如何查看实际成交价?
1. 进入 **跟单统计****卖出订单**
2. 查看订单列表,**卖出价格** 列显示的是实际成交价
3. 如果价格旁边有更新标记,说明价格已从订单详情中获取
**注意**
- 价格更新是自动的,每 5 秒检查一次
- 如果订单还未成交,价格可能还是下单时的价格
- 部分成交的订单会使用加权平均价格
## 🔍 常见问题
### Q1: 更新后无法启动怎么办?
**A:** 请按以下步骤排查:
1. 检查日志:`docker-compose -f docker-compose.prod.yml logs`
2. 检查数据库连接是否正常
3. 检查端口是否被占用
4. 如果问题持续,可以回退到之前的版本:
```bash
# 修改 docker-compose.prod.yml 中的镜像标签为之前的版本
# 例如:image: wrbug/polyhermes:v1.0.3
docker-compose -f docker-compose.prod.yml up -d
```
### Q2: 更新后跟单不工作怎么办?
**A:** 请检查:
1. RPC 节点是否正常(进入 RPC 节点设置查看)
2. 跟单配置是否启用
3. 账户 API 凭证是否有效
4. 查看系统日志是否有错误信息
### Q3: 如何回退到之前的版本?
**A:** 按以下步骤操作:
1. 停止当前服务:`docker-compose -f docker-compose.prod.yml down`
2. 修改 `docker-compose.prod.yml` 中的镜像标签为之前的版本
3. 启动服务:`docker-compose -f docker-compose.prod.yml up -d`
**注意**:回退版本时,数据库结构可能不兼容,建议先备份数据库。
### Q4: 更新后数据会丢失吗?
**A:** 不会。更新过程不会删除任何数据。但为了安全起见,建议更新前备份数据库。
### Q5: 如何查看当前版本?
**A:** 有两种方式:
1. **页面查看**:登录系统后,页面标题会显示版本号(如:PolyHermes v1.1.0
2. **命令行查看**
```bash
docker inspect polyhermes | grep -i version
```
## 📚 相关文档
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.0.3...v1.1.0)
- [部署文档](docs/zh/DEPLOYMENT.md)
- [版本管理文档](docs/zh/VERSION_MANAGEMENT.md)
## 🔗 获取帮助
如果更新过程中遇到问题:
1. 查看 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
2. 查看系统日志:`docker-compose -f docker-compose.prod.yml logs -f`
3. 在 GitHub 上提交 Issue,描述您的问题
## ⚠️ 安全提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.0
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
+21 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@quant__tr-blue?logo=twitter)](https://x.com/quant_tr)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](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
@@ -414,7 +433,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 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@quant__tr-blue?logo=twitter)](https://x.com/quant_tr)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](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
@@ -414,7 +433,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)
+95
View File
@@ -0,0 +1,95 @@
# 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.0
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.3...v1.1.0
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.0
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
@@ -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
)
@@ -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()
)
@@ -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>
}
@@ -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,
@@ -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 解析 resultJsonElement
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 解析 resultJsonElement
val hexBalance = rpcResponse.result?.asString
?: throw Exception("RPC 响应格式错误: result 为空")
// 将十六进制转换为 BigDecimalUSDC 有 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 解析 resultJsonElement
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 解析 resultJsonElement
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 解析 resultJsonElement
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 解析 resultJsonElement
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 解析 resultJsonElement
val hexGasPrice = rpcResponse.result?.asString
?: return Result.failure(Exception("gas price 结果为空"))
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice)
return Result.success(gasPrice)
}
@@ -603,7 +596,9 @@ class BlockchainService(
return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}"))
}
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
// 使用 Gson 解析 resultJsonElement
val txHash = rpcResponse.result?.asString
?: return Result.failure(Exception("交易哈希为空"))
return Result.success(txHash)
}
@@ -614,11 +609,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 +627,9 @@ class BlockchainService(
return Result.failure(Exception("查询交易失败: ${txRpcResponse.error.message}"))
}
val txResult = txRpcResponse.result ?: return Result.failure(Exception("交易结果为空"))
// 使用 Gson 解析 resultJsonElement
val txResult = txRpcResponse.result?.toString()
?: return Result.failure(Exception("交易结果为空"))
// 查询交易回执(包含内部调用和事件日志)
val receiptRequest = JsonRpcRequest(
@@ -653,7 +646,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")
@@ -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}")
}
}
}
@@ -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,21 @@ 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)
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
logger.error("更新 Leader 监听失败", e)
}
}
}
@@ -204,6 +210,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 +219,12 @@ class CopyTradingService(
val saved = copyTradingRepository.save(updated)
// 重新启动监听(确保状态完全同步
// 更新 Leader 监听(增量更新,根据 enabled 状态决定添加或移除
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
monitorService.updateLeaderMonitoring(saved.leaderId)
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
logger.error("更新 Leader 监听失败", e)
}
}
@@ -314,14 +322,15 @@ class CopyTradingService(
val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
val leaderId = copyTrading.leaderId
copyTradingRepository.delete(copyTrading)
// 重新启动监听(确保状态完全同步
// 更新 Leader 监听(检查该 Leader 是否还有其他启用的跟单配置
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
monitorService.removeLeaderMonitoring(leaderId)
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
logger.error("更新 Leader 监听失败", e)
}
}
@@ -409,6 +418,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 +448,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,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
)
}
}
@@ -12,14 +12,15 @@ import org.springframework.stereotype.Service
/**
* 跟单监听服务(主服务)
* 管理所有Leader的交易监听(使用轮询方式)
* 注意:WebSocket 需要认证才能订阅其他用户的交易,因此只使用轮询方式
* 管理所有Leader的交易监听
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
*/
@Service
class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val pollingService: CopyTradingPollingService
private val pollingService: CopyTradingPollingService,
private val onChainWsService: OnChainWsService
) {
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
@@ -46,12 +47,14 @@ class CopyTradingMonitorService(
@PreDestroy
fun destroy() {
scope.cancel()
// 只使用轮询,不使用WebSocket
// 停止轮询和链上 WS 监听
pollingService.stop()
onChainWsService.stop()
}
/**
* 启动监听
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
*/
suspend fun startMonitoring() {
// 1. 获取所有启用的跟单关系
@@ -67,14 +70,17 @@ class CopyTradingMonitorService(
leaderRepository.findById(leaderId).orElse(null)
}
// 3. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
// 链上 WS 监听(实时,秒级延迟)
onChainWsService.start(leaders)
// 3. 启动轮询监听(使用 /activity 接口,不需要认证
// 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询
// 轮询监听(延迟,2秒间隔,作为备份
pollingService.start(leaders)
}
/**
* 添加Leader监听(当创建新的跟单关系时调用)
* 如果 Leader 已经在监听列表中,不重复添加
*/
suspend fun addLeaderMonitoring(leaderId: Long) {
val leader = leaderRepository.findById(leaderId).orElse(null)
@@ -85,28 +91,54 @@ 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)
} else {
// 没有启用的跟单配置,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
}
/**
* 重新启动监听(当跟单关系状态改变时调用)
* 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新
*/
suspend fun restartMonitoring() {
// 只使用轮询,不使用WebSocket
// 停止所有监听
onChainWsService.stop()
pollingService.stop()
delay(1000) // 等待1秒
startMonitoring()
@@ -0,0 +1,925 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.reflect.TypeToken
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.service.system.RpcNodeService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.getProxyConfig
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.math.BigInteger
import java.util.concurrent.ConcurrentHashMap
/**
* 链上 WebSocket 监听服务
* 通过 Polygon RPC 的 eth_subscribe 实时监听链上交易
*/
@Service
class OnChainWsService(
private val rpcNodeService: RpcNodeService,
private val retrofitFactory: RetrofitFactory,
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(OnChainWsService::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())
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储每个 Leader 的订阅 IDleaderId -> List<subscriptionId>
// 每个 Leader 有 6 个订阅:USDC from/to, ERC1155 TransferSingle from/to, ERC1155 TransferBatch from/to
private val leaderSubscriptions = ConcurrentHashMap<Long, MutableList<String>>()
// 存储请求 ID 到 Leader ID 的映射:requestId -> leaderId
// 用于在收到订阅响应时,将 subscription ID 关联到对应的 Leader
private val requestIdToLeaderId = ConcurrentHashMap<Int, Long>()
// WebSocket 连接
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID)
private var requestIdCounter = 0
// 合约地址
companion object {
private const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
private const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
private const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
private const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
private const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
}
// 连接任务(确保只有一个连接任务在运行)
private var connectionJob: Job? = null
/**
* 启动链上 WebSocket 监听
* 只创建一个 WebSocket 连接,为所有 Leader 订阅
*/
fun start(leaders: List<Leader>) {
// 如果没有 Leader,不启动连接
if (leaders.isEmpty()) {
logger.info("没有需要监听的 Leader,不启动链上 WebSocket 连接")
stop()
return
}
// 如果连接任务已经在运行,先停止旧任务
if (connectionJob != null && connectionJob!!.isActive) {
logger.info("停止旧的连接任务,准备重新启动")
connectionJob?.cancel()
connectionJob = null
// 关闭旧连接
webSocket?.close(1000, "重新启动")
webSocket = null
isConnected = false
}
// 更新 Leader 列表
monitoredLeaders.clear()
leaders.forEach { leader ->
addLeader(leader)
}
// 启动连接任务(只创建一个)
connectionJob = scope.launch {
startConnection()
}
}
/**
* 添加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
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
// 如果已连接,立即订阅
if (isConnected && webSocket != null) {
scope.launch {
subscribeLeader(leader)
}
} else {
// 如果未连接,启动连接(如果连接任务未运行)
if (connectionJob == null || !connectionJob!!.isActive) {
connectionJob = scope.launch {
startConnection()
}
}
}
}
/**
* 移除Leader监听
* 通过 eth_unsubscribe 取消该 Leader 的所有订阅
* 如果没有 Leader 了,关闭 WebSocket 连接
*/
fun removeLeader(leaderId: Long) {
val leader = monitoredLeaders.remove(leaderId)
if (leader != null) {
logger.info("移除 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
}
// 取消该 Leader 的所有订阅
val subscriptions = leaderSubscriptions.remove(leaderId)
if (subscriptions != null && subscriptions.isNotEmpty() && isConnected && webSocket != null) {
logger.info("取消 Leader ${leader?.leaderName}${subscriptions.size} 个订阅")
subscriptions.forEach { subscriptionId ->
unsubscribe(subscriptionId)
}
}
// 如果没有 Leader 了,关闭连接
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,关闭链上 WebSocket 连接")
stop()
}
}
/**
* 停止所有监听
*/
fun stop() {
connectionJob?.cancel()
connectionJob = null
// 取消所有订阅
if (isConnected && webSocket != null) {
leaderSubscriptions.values.flatten().forEach { subscriptionId ->
unsubscribe(subscriptionId)
}
}
webSocket?.close(1000, "正常关闭")
webSocket = null
isConnected = false
monitoredLeaders.clear()
leaderSubscriptions.clear()
requestIdToLeaderId.clear()
}
/**
* 启动连接(带重连机制)
* 只创建一个 WebSocket 连接
*/
private suspend fun startConnection() {
while (scope.isActive) {
try {
// 检查是否有需要监听的 Leader
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,停止连接")
// 确保关闭连接
webSocket?.close(1000, "没有 Leader")
webSocket = null
isConnected = false
break
}
// 如果已经连接,不需要重新连接
if (isConnected && webSocket != null) {
// 等待连接断开
waitForDisconnect()
// 连接断开后继续重连循环
continue
}
// 从后台配置获取 WS RPC URL
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
logger.info("连接链上 WebSocket: $wsUrl (监听 ${monitoredLeaders.size} 个 Leader)")
// 创建 HTTP 客户端(用于 RPC 调用)
val httpClient = createHttpClient()
// 创建 RPC API 客户端
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
// 连接 WebSocket(只创建一个连接,会先关闭旧连接)
connectWebSocket(wsUrl, httpClient, rpcApi)
// 等待连接建立(最多等待 15 秒)
// 注意:onOpen 回调是异步的,需要等待一段时间
var waitCount = 0
val maxWait = 15 // 最多等待 15 秒
while (!isConnected && waitCount < maxWait && scope.isActive) {
delay(1000)
waitCount++
// 每 3 秒打印一次日志,方便调试
if (waitCount % 3 == 0) {
logger.debug("等待 WebSocket 连接建立... (${waitCount}/${maxWait}秒)")
}
}
// 检查连接状态(同时检查 isConnected 和 webSocket 状态)
val actuallyConnected = isConnected && webSocket != null
// 如果连接失败,等待重连延迟后继续
if (!actuallyConnected) {
logger.warn("WebSocket 连接超时或失败: isConnected=$isConnected, webSocket=${webSocket != null}, 等待重连")
delay(reconnectDelay)
continue
}
logger.info("WebSocket 连接已建立,开始监听")
// 连接成功后持续监听
waitForDisconnect()
// 连接断开后,如果没有 Leader 了,不再重连
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,停止重连")
break
}
// 连接断开后,等待一下再重连(避免立即重连)
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
// 如果没有 Leader 了,不再重连
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,停止重连")
// 确保关闭连接
webSocket?.close(1000, "没有 Leader")
webSocket = null
isConnected = false
break
}
logger.warn("链上 WebSocket 连接失败,等待重连: ${e.message}")
// 确保关闭旧连接
webSocket?.close(1000, "重连前关闭")
webSocket = null
isConnected = false
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) {
// 先关闭旧连接(如果存在)
val oldWebSocket = webSocket
if (oldWebSocket != null) {
try {
oldWebSocket.close(1000, "重新连接")
} catch (e: Exception) {
logger.debug("关闭旧 WebSocket 连接时出错: ${e.message}")
}
}
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 连接成功")
// 订阅所有 Leader
scope.launch {
monitoredLeaders.values.forEach { leader ->
subscribeLeader(leader)
}
}
}
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
// 注意:这里不直接重连,由 startConnection 循环处理重连逻辑
}
})
}
/**
* 等待连接断开
*/
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
/**
* 订阅 Leader 钱包地址
* 每个 Leader 有 6 个订阅,保存订阅 ID 以便后续取消
*/
private suspend fun subscribeLeader(leader: Leader) {
if (webSocket == null || !isConnected || leader.id == null) {
return
}
val walletAddress = leader.leaderAddress.lowercase()
val walletTopic = addressToTopic32(walletAddress)
val leaderId = leader.id!!
// 初始化该 Leader 的订阅列表
if (!leaderSubscriptions.containsKey(leaderId)) {
leaderSubscriptions[leaderId] = mutableListOf()
}
try {
// 订阅 USDC Transfer (from wallet)
subscribeLogs(USDC_CONTRACT, listOf(ERC20_TRANSFER_TOPIC, walletTopic), leaderId)
// 订阅 USDC Transfer (to wallet)
subscribeLogs(USDC_CONTRACT, listOf(ERC20_TRANSFER_TOPIC, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferSingle (from wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferSingle (to wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferBatch (from wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferBatch (to wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), leaderId)
logger.debug("已订阅 Leader 钱包地址: ${leader.leaderName} (${walletAddress})")
} catch (e: Exception) {
logger.error("订阅 Leader 失败: leaderId=$leaderId, address=$walletAddress", e)
}
}
/**
* 订阅日志
* @param address 合约地址
* @param topics 主题列表
* @param leaderId Leader ID,用于关联订阅响应
*/
private fun subscribeLogs(address: String, topics: List<String?>, leaderId: Long) {
val ws = webSocket ?: return
val params = mapOf(
"address" to address.lowercase(),
"topics" to topics
)
val subscribeParams = listOf("logs", params)
val requestId = ++requestIdCounter
// 保存请求 ID 到 Leader ID 的映射
requestIdToLeaderId[requestId] = leaderId
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_subscribe",
"params" to subscribeParams
)
val json = gson.toJson(request)
ws.send(json)
}
/**
* 取消订阅
* @param subscriptionId 订阅 ID
*/
private fun unsubscribe(subscriptionId: String) {
val ws = webSocket ?: return
val request = mapOf(
"jsonrpc" to "2.0",
"id" to (++requestIdCounter),
"method" to "eth_unsubscribe",
"params" to listOf(subscriptionId)
)
val json = gson.toJson(request)
ws.send(json)
logger.debug("已发送取消订阅请求: subscriptionId=$subscriptionId")
}
/**
* 处理 WebSocket 消息
*/
private suspend fun handleMessage(message: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 使用 Gson 解析消息
val messageJson = gson.fromJson(message, JsonObject::class.java)
// 处理订阅响应(包含 subscription ID
val id = messageJson.get("id")
if (id != null && !id.isJsonNull) {
val result = messageJson.get("result")
if (result != null && result.isJsonPrimitive && result.asJsonPrimitive.isString) {
// 这是订阅响应,result 是 subscription ID(字符串)
val requestId = id.asInt
val subscriptionId = result.asString
val leaderId = requestIdToLeaderId.remove(requestId)
if (leaderId != null) {
// 保存订阅 ID 到 Leader
leaderSubscriptions.getOrPut(leaderId) { mutableListOf() }.add(subscriptionId)
logger.debug("收到订阅响应: leaderId=$leaderId, subscriptionId=$subscriptionId")
}
}
return
}
// 处理订阅通知(交易日志)
val method = messageJson.get("method")?.asString
if (method != "eth_subscription") {
return
}
val params = messageJson.getAsJsonObject("params") ?: return
// result 是一个对象,包含日志信息
val result = params.getAsJsonObject("result") ?: return
// 从 result 对象中获取 transactionHash(关键数据)
val txHash = result.get("transactionHash")?.asString
if (txHash.isNullOrEmpty()) {
logger.debug("订阅通知中缺少 transactionHash,跳过处理")
return
}
// 处理交易
processTransaction(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
logger.debug("消息内容: $message", e)
}
}
/**
* 处理交易
*/
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 JSONresult 是 JsonElement
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
getBlockTimestamp(blockNumber, rpcApi)
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = parseReceiptTransfers(logs)
// 为每个 Leader 处理交易
for (leader in monitoredLeaders.values) {
val trade = parseTradeFromTransfers(
txHash = txHash,
timestamp = blockTimestamp,
walletAddress = leader.leaderAddress,
erc20Transfers = erc20Transfers,
erc1155Transfers = erc1155Transfers
)
if (trade != null) {
// 调用 processTrade 处理交易(元数据已在 parseTradeFromTransfers 中补齐)
copyOrderTrackingService.processTrade(
leaderId = leader.id!!,
trade = trade,
source = "onchain-ws"
)
}
}
} catch (e: Exception) {
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
}
}
/**
* 解析 receipt 中的 Transfer 日志
*/
private fun parseReceiptTransfers(logs: com.google.gson.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 日志解析交易信息
*/
private suspend fun parseTradeFromTransfers(
txHash: String,
timestamp: Long?,
walletAddress: String,
erc20Transfers: List<Erc20Transfer>,
erc1155Transfers: List<Erc1155Transfer>
): 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())
// 创建 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)
*/
private suspend fun fetchMarketByTokenId(tokenId: String): MarketInfo? {
return try {
// 使用 HTTP 请求直接调用 Gamma API(因为 Retrofit 接口可能不支持 clob_token_ids 参数)
val httpClient = createHttpClient()
val url = "https://gamma-api.polymarket.com/markets?clob_token_ids=$tokenId"
val request = okhttp3.Request.Builder()
.url(url)
.get()
.build()
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful || response.body == null) {
return null
}
val responseBody = response.body!!.string()
// 使用 Gson 解析市场列表
val marketsType = object : TypeToken<List<JsonObject>>() {}.type
val markets = gson.fromJson<List<JsonObject>>(responseBody, marketsType)
if (markets.isEmpty()) {
return null
}
val market = markets.first()
// 解析 clob_token_ids(可能是 JSON 字符串或数组)
val clobTokenIdsRaw = market.get("clobTokenIds") ?: market.get("clob_token_ids")
val clobTokenIds = when {
clobTokenIdsRaw == null || clobTokenIdsRaw.isJsonNull -> null
clobTokenIdsRaw.isJsonPrimitive && clobTokenIdsRaw.asJsonPrimitive.isString -> {
// 尝试解析 JSON 字符串
try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(clobTokenIdsRaw.asString, listType)
} catch (e: Exception) {
null
}
}
clobTokenIdsRaw.isJsonArray -> {
clobTokenIdsRaw.asJsonArray.mapNotNull { it.asString }
}
else -> null
}
// 解析 outcomes(可能是 JSON 字符串或数组)
val outcomesRaw = market.get("outcomes")
val outcomes = when {
outcomesRaw == null || outcomesRaw.isJsonNull -> null
outcomesRaw.isJsonPrimitive && outcomesRaw.asJsonPrimitive.isString -> {
try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(outcomesRaw.asString, listType)
} catch (e: Exception) {
null
}
}
outcomesRaw.isJsonArray -> {
outcomesRaw.asJsonArray.mapNotNull { it.asString }
}
else -> null
}
// 查找 tokenId 在 clobTokenIds 中的索引
val outcomeIndex = clobTokenIds?.indexOfFirst {
it.equals(tokenId, ignoreCase = true)
}?.takeIf { it >= 0 }
// 获取 outcome 名称
val outcome = if (outcomeIndex != null && outcomes != null && outcomeIndex < outcomes.size) {
outcomes[outcomeIndex]
} else {
null
}
val conditionId = market.get("conditionId")?.asString ?: return null
MarketInfo(
conditionId = conditionId,
outcomeIndex = outcomeIndex,
outcome = outcome
)
} catch (e: Exception) {
logger.warn("通过 Gamma API 查询市场信息失败: tokenId=$tokenId, ${e.message}")
null
}
}
/**
* 市场信息(从 Gamma API 获取)
*/
private data class MarketInfo(
val conditionId: String,
val outcomeIndex: Int?,
val outcome: String?
)
/**
* 获取区块时间戳
*/
private 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) {
return null
}
val blockRpcResponse = blockResponse.body()!!
if (blockRpcResponse.error != null || blockRpcResponse.result == null) {
return null
}
// 使用 Gson 解析 block JSONresult 是 JsonElement
val blockJson = blockRpcResponse.result.asJsonObject
val timestampHex = blockJson.get("timestamp")?.asString ?: return null
hexToBigInt(timestampHex).toLong()
} catch (e: Exception) {
logger.warn("获取区块时间戳失败: ${e.message}")
null
}
}
// 辅助函数
private data class Erc20Transfer(val from: String, val to: String, val value: BigInteger)
private data class Erc1155Transfer(val from: String, val to: String, val tokenId: BigInteger, val value: BigInteger)
private fun topicToAddress(topic: String): String {
val t = topic.removePrefix("0x").lowercase()
return "0x" + t.takeLast(40)
}
private fun hexToBigInt(hex: String): BigInteger {
val h = hex.removePrefix("0x")
if (h.isEmpty()) return BigInteger.ZERO
return BigInteger(h, 16)
}
private fun bytesFromHex(hex: String): ByteArray {
val s = hex.removePrefix("0x")
if (s.isEmpty()) return ByteArray(0)
val out = ByteArray(s.length / 2)
var i = 0
while (i < s.length) {
out[i / 2] = s.substring(i, i + 2).toInt(16).toByte()
i += 2
}
return out
}
private fun sliceBigInt32(b: ByteArray, offset: Int): BigInteger {
val sub = b.copyOfRange(offset, offset + 32)
return BigInteger(1, sub)
}
/**
* 地址转换为 32 字节 topic(前 24 字节为 0,后 8 字节为地址)
*/
private fun addressToTopic32(address: String): String {
val addr = address.removePrefix("0x").lowercase()
return "0x" + "0".repeat(24) + addr
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -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)
// 转换为 wei6 位小数)
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())
}
@@ -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()
// 持仓统计
@@ -0,0 +1,698 @@
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}")
// 发送通知(使用临时数据)
sendSellOrderNotification(
record = record,
useTemporaryData = true,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
// 标记为已处理(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
}
// 查询订单详情,获取实际成交价
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)
}
}
}
@@ -33,7 +33,8 @@ class ApiHealthCheckService(
@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 +187,28 @@ class ApiHealthCheckService(
/**
* 检查 Polygon RPC
* 使用动态获取的可用节点,而不是固定的配置
*/
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 rpcUrl = try {
rpcNodeService.getHttpUrl()
} catch (e: Exception) {
logger.debug("获取可用 RPC 节点失败,使用配置的默认值: ${e.message}")
// 如果获取失败,使用配置的默认值作为兜底
if (polygonRpcUrl.isNotBlank()) {
polygonRpcUrl
} else {
return@withContext ApiHealthCheckDto(
name = "Polygon RPC",
url = "未配置",
status = "skipped",
message = "未配置 Polygon RPC URL 且没有可用的节点"
)
}
}
val url = polygonRpcUrl
checkJsonRpcApi("Polygon RPC", url)
checkJsonRpcApi("Polygon RPC", rpcUrl)
}
/**
@@ -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 RelayerGasless"))
}
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
)
}
@@ -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()!!
actualPrice = order.price
actualSize = order.originalSize // 使用 originalSize 作为订单数量
if (actualPrice == null) {
actualPrice = order.price
}
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
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 × sizeUSDC
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,
@@ -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()
// 使用固定的 baseUrlRetrofit 要求 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 解析错误
@@ -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'
);
@@ -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;
@@ -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;
@@ -0,0 +1,7 @@
-- 添加 Leader 买入数量字段,用于固定金额模式计算卖出比例
ALTER TABLE copy_order_tracking
ADD COLUMN leader_buy_quantity DECIMAL(20, 8) DEFAULT NULL COMMENT 'Leader 买入数量(用于固定金额模式计算卖出比例)';
-- 对于已有数据,如果无法从 API 查询,设置为 NULL(不影响现有功能)
-- 新创建的记录会在创建时自动填充此字段
+33 -1
View File
@@ -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.
@@ -648,5 +680,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).
+37 -1
View File
@@ -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. **本地构建部署(开发环境)**
适用于开发环境或需要自定义构建的场景。
@@ -648,5 +684,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)。
+289
View File
@@ -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=第一个outcome1=第二个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 位小数)
- 转换为 wei6 位小数)
2. **生成订单参数**
- `salt`: 时间戳(毫秒)
- `maker`: 代理钱包地址(proxyAddress
- `signer`: 从私钥推导的签名地址
- `taker`: 零地址(0x0000...
- `tokenId`: 从 outcomeIndex 获取
- `makerAmount`: 计算出的 maker 金额(wei
- `takerAmount`: 计算出的 taker 金额(wei
- `expiration`: "0"(永不过期)
- `nonce`: "0"
- `feeRateBps`: "0"
- `side`: "BUY"
- `signatureType`: 2Browser 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`
+653
View File
@@ -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 问题,可能创建重复订单)
- ❌ **不推荐**:数据库锁或分布式锁(单实例场景过于复杂)
+2 -1
View File
@@ -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
+172
View File
@@ -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
+8 -3
View File
@@ -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' }}
+136
View File
@@ -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
+95 -2
View File
@@ -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"
}
}
+187 -11
View File
@@ -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"
}
}
+95 -2
View File
@@ -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"
}
}
+12 -294
View File
@@ -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>
)
+168 -4
View File
@@ -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>
)
}
+33
View File
@@ -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
+12 -123
View File
@@ -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>
)
+3 -17
View File
@@ -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
+3 -37
View File
@@ -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>
{/* 创建时间 */}
+258
View File
@@ -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
+15 -11
View File
@@ -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>
)
+27
View File
@@ -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
+63
View File
@@ -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
}