From 5e544cda075a3af5869ec87816228b22f3ad9a9e Mon Sep 17 00:00:00 2001 From: WrBug Date: Fri, 26 Dec 2025 05:42:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E6=A8=A1=E5=BC=8F=E5=8D=96=E5=87=BA=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除卖出订单的 notificationSent 字段,使用 priceUpdated 作为共用字段 - 添加 leaderBuyQuantity 字段,在创建 CopyOrderTracking 时存储 Leader 买入数量 - 修复固定金额模式下卖出数量计算:优先使用存储的 leaderBuyQuantity,避免 API 查询失败 - 添加数据库迁移 V14 和 V15 - 优化 calculateSellQuantityForFixedMode 方法,支持新数据和旧数据兼容 --- ANNOUNCEMENT_v1.1.0.md | 288 ++++++++++++ RELEASE_v1.1.0.md | 95 ++++ .../polymarketbot/entity/CopyOrderTracking.kt | 6 + .../polymarketbot/entity/SellMatchRecord.kt | 2 +- .../repository/CopyOrderTrackingRepository.kt | 5 + .../repository/SellMatchRecordRepository.kt | 1 + .../service/accounts/AccountService.kt | 2 + .../statistics/CopyOrderTrackingService.kt | 229 +++++---- .../statistics/OrderStatusUpdateService.kt | 437 +++++++++++++++++- .../system/TelegramNotificationService.kt | 42 +- ...tification_sent_to_copy_order_tracking.sql | 8 + ...er_buy_quantity_to_copy_order_tracking.sql | 7 + docs/zh/DEPLOYMENT.md | 6 +- 13 files changed, 1006 insertions(+), 122 deletions(-) create mode 100644 ANNOUNCEMENT_v1.1.0.md create mode 100644 RELEASE_v1.1.0.md create mode 100644 backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql create mode 100644 backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql diff --git a/ANNOUNCEMENT_v1.1.0.md b/ANNOUNCEMENT_v1.1.0.md new file mode 100644 index 0000000..b01926d --- /dev/null +++ b/ANNOUNCEMENT_v1.1.0.md @@ -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 中反馈。** + diff --git a/RELEASE_v1.1.0.md b/RELEASE_v1.1.0.md new file mode 100644 index 0000000..6ffdac4 --- /dev/null +++ b/RELEASE_v1.1.0.md @@ -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 支持一下!** + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt index 10bdd14..be0224b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt index 04e8e2d..9afbcba 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt @@ -42,7 +42,7 @@ data class SellMatchRecord( val totalRealizedPnl: BigDecimal, // 总已实现盈亏 @Column(name = "price_updated", nullable = false) - var priceUpdated: Boolean = false, // 价格是否已更新(从订单详情获取实际成交价) + var priceUpdated: Boolean = false, // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知) @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis() diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt index 7b6ef07..f49051b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt @@ -50,5 +50,10 @@ interface CopyOrderTrackingRepository : JpaRepository { * 根据买入订单ID查询订单跟踪记录 */ fun findByBuyOrderId(buyOrderId: String): List + + /** + * 查询未发送通知的买入订单(用于轮询更新) + */ + fun findByNotificationSentFalse(): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt index 47a7e24..51131d1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt @@ -27,6 +27,7 @@ interface SellMatchRecordRepository : JpaRepository { /** * 查询所有价格未更新的卖出记录 + * 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段) */ fun findByPriceUpdatedFalse(): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index f7c4bc1..2675d1d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -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, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index 0ff7c86..4e29087 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -575,6 +575,7 @@ open class CopyOrderTrackingService( } // 创建买入订单跟踪记录(使用真实订单ID,使用outcomeIndex) + // 先使用下单时的价格和数量作为临时值,等待轮询任务获取实际数据后再发送通知 val tracking = CopyOrderTracking( copyTradingId = copyTrading.id, accountId = copyTrading.accountId, @@ -584,94 +585,17 @@ open class CopyOrderTrackingService( outcomeIndex = trade.outcomeIndex, // 新增字段 buyOrderId = realOrderId, // 使用真实订单ID leaderBuyTradeId = trade.id, - quantity = finalBuyQuantity, // 使用最终数量(可能已调整) - price = buyPrice, + leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例) + quantity = finalBuyQuantity, // 使用最终数量(可能已调整),临时值 + price = buyPrice, // 使用下单价格,临时值 remainingQuantity = finalBuyQuantity, - status = "filled" + status = "filled", + notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送 ) copyOrderTrackingRepository.save(tracking) - - // 发送订单成功通知(异步,不阻塞) - notificationScope.launch { - try { - // 获取市场信息(标题和slug) - val marketInfo = withContext(Dispatchers.IO) { - try { - val gammaApi = retrofitFactory.createGammaApi() - val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market)) - if (marketResponse.isSuccessful && marketResponse.body() != null) { - marketResponse.body()!!.firstOrNull() - } else { - null - } - } catch (e: Exception) { - logger.warn("获取市场信息失败: ${e.message}", e) - null - } - } - - val marketTitle = marketInfo?.question ?: trade.market - val marketSlug = marketInfo?.slug - - // 重新创建 CLOB API 客户端用于查询订单详情 - val apiSecret = try { - decryptApiSecret(account) - } catch (e: Exception) { - logger.warn("解密 API Secret 失败: ${e.message}", e) - null - } - val apiPassphrase = try { - decryptApiPassphrase(account) - } catch (e: Exception) { - logger.warn("解密 API Passphrase 失败: ${e.message}", e) - null - } - - val clobApiForQuery = if (apiSecret != null && apiPassphrase != null) { - retrofitFactory.createClobApi( - account.apiKey, - apiSecret, - apiPassphrase, - account.walletAddress - ) - } else { - null - } - - // 获取当前语言设置(从 LocaleContextHolder) - val locale = try { - org.springframework.context.i18n.LocaleContextHolder.getLocale() - } catch (e: Exception) { - java.util.Locale("zh", "CN") // 默认简体中文 - } - - // 获取 Leader 和跟单配置信息 - val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) - val leaderName = leader?.leaderName - val configName = copyTrading.configName - - telegramNotificationService?.sendOrderSuccessNotification( - orderId = realOrderId, - marketTitle = marketTitle, - marketId = trade.market, - marketSlug = marketSlug, - side = "BUY", - accountName = account.accountName, - walletAddress = account.walletAddress, - clobApi = clobApiForQuery, - apiKey = account.apiKey, - apiSecret = apiSecret, - apiPassphrase = apiPassphrase, - walletAddressForApi = account.walletAddress, - locale = locale, - leaderName = leaderName, - configName = configName - ) - } catch (e: Exception) { - logger.warn("发送订单成功通知失败: ${e.message}", e) - } - } + + logger.info("买入订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realOrderId, copyTradingId=${copyTrading.id}") } catch (e: Exception) { logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e) // 继续处理下一个跟单关系 @@ -745,9 +669,113 @@ open class CopyOrderTrackingService( } } + /** + * 计算固定金额模式下的卖出数量 + * 根据未匹配订单的实际买入比例计算 + */ + private suspend fun calculateSellQuantityForFixedMode( + unmatchedOrders: List, + leaderSellQuantity: BigDecimal, + copyTrading: CopyTrading + ): BigDecimal { + if (unmatchedOrders.isEmpty()) { + return BigDecimal.ZERO + } + + // 获取 Leader 信息(用于查询 Leader 买入交易) + val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) + ?: run { + logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}") + return leaderSellQuantity.multi(copyTrading.copyRatio) + } + + // 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据) + // 注意:Polymarket CLOB API 的 /data/trades 接口是公开的,不需要认证 + val clobApi = retrofitFactory.createClobApiWithoutAuth() + + // 计算总比例:sum(跟单买入数量) / sum(Leader 买入数量) + // 优先使用存储的 leaderBuyQuantity,如果不存在则尝试查询 API(兼容旧数据) + var totalCopyQuantity = BigDecimal.ZERO + var totalLeaderQuantity = BigDecimal.ZERO + var successCount = 0 + var failCount = 0 + + logger.debug("开始计算固定金额模式卖出数量: copyTradingId=${copyTrading.id}, unmatchedOrdersCount=${unmatchedOrders.size}, leaderSellQuantity=$leaderSellQuantity") + + for (order in unmatchedOrders) { + val copyQty = order.quantity.toSafeBigDecimal() + var leaderQty: BigDecimal? = null + + // 优先使用存储的 leaderBuyQuantity + if (order.leaderBuyQuantity != null) { + leaderQty = order.leaderBuyQuantity.toSafeBigDecimal() + logger.debug("使用存储的 Leader 买入数量: copyOrderId=${order.buyOrderId}, copyQty=$copyQty, leaderQty=$leaderQty") + successCount++ + } else { + // 兼容旧数据:如果 leaderBuyQuantity 为空,尝试查询 API + logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}") + try { + val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId) + + if (tradesResponse.isSuccessful && tradesResponse.body() != null) { + val tradesData = tradesResponse.body()!!.data + if (tradesData.isNotEmpty()) { + val leaderBuyTrade = tradesData.firstOrNull() + if (leaderBuyTrade != null) { + leaderQty = leaderBuyTrade.size.toSafeBigDecimal() + logger.debug("从 API 查询到 Leader 买入数量: leaderBuyTradeId=${order.leaderBuyTradeId}, leaderQty=$leaderQty") + successCount++ + } else { + logger.warn("未找到 Leader 买入交易: leaderBuyTradeId=${order.leaderBuyTradeId}") + failCount++ + } + } else { + logger.warn("Leader 买入交易数据为空: leaderBuyTradeId=${order.leaderBuyTradeId}") + failCount++ + } + } else { + logger.warn("查询 Leader 买入交易失败: leaderBuyTradeId=${order.leaderBuyTradeId}, code=${tradesResponse.code()}") + failCount++ + } + } catch (e: Exception) { + logger.warn("查询 Leader 买入交易异常: leaderBuyTradeId=${order.leaderBuyTradeId}, error=${e.message}") + failCount++ + } + } + + // 如果成功获取到 Leader 买入数量,累加 + if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) { + totalCopyQuantity = totalCopyQuantity.add(copyQty) + totalLeaderQuantity = totalLeaderQuantity.add(leaderQty) + } else { + logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}") + } + } + + logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity") + + // 如果无法计算总比例(查询失败),使用默认比例 + if (totalLeaderQuantity.lte(BigDecimal.ZERO)) { + logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}") + return leaderSellQuantity.multi(copyTrading.copyRatio) + } + + // 计算实际比例:跟单买入数量 / Leader 买入数量 + val actualRatio = totalCopyQuantity.div(totalLeaderQuantity) + + // 计算需要卖出的数量:Leader 卖出数量 × 实际比例 + val needMatch = leaderSellQuantity.multi(actualRatio) + + logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch") + + return needMatch + } + /** * 卖出订单匹配 - * 统一按比例计算,不区分RATIO或FIXED模式 + * 根据 copyMode 计算卖出数量: + * - RATIO 模式:使用配置的 copyRatio + * - FIXED 模式:根据实际买入比例计算 * 实际创建卖出订单并记录匹配关系 * 注意:此方法在 @Transactional 方法中被调用,会自动继承事务 */ @@ -773,10 +801,7 @@ open class CopyOrderTrackingService( return } - // 2. 计算需要匹配的数量(统一按比例计算) - val needMatch = leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) - - // 3. 查找未匹配的买入订单(FIFO顺序) + // 2. 查找未匹配的买入订单(FIFO顺序) // 直接使用outcomeIndex匹配,而不是转换为YES/NO if (leaderSellTrade.outcomeIndex == null) { logger.warn("卖出交易缺少outcomeIndex,无法匹配: tradeId=${leaderSellTrade.id}, market=${leaderSellTrade.market}") @@ -794,6 +819,28 @@ open class CopyOrderTrackingService( return } + // 3. 计算需要匹配的数量 + // 对于 FIXED 模式,需要根据实际买入比例计算;对于 RATIO 模式,使用配置的 copyRatio + val needMatch = when (copyTrading.copyMode) { + "FIXED" -> { + // 固定金额模式:根据未匹配订单的实际比例计算 + // 需要查询每个订单对应的 Leader 买入交易,计算实际比例 + calculateSellQuantityForFixedMode( + unmatchedOrders = unmatchedOrders, + leaderSellQuantity = leaderSellTrade.size.toSafeBigDecimal(), + copyTrading = copyTrading + ) + } + "RATIO" -> { + // 比例模式:直接使用配置的 copyRatio + leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) + } + else -> { + logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式") + leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) + } + } + // 4. 获取tokenId(直接使用outcomeIndex,支持多元市场) val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex) if (tokenIdResult.isFailure) { @@ -996,7 +1043,7 @@ open class CopyOrderTrackingService( totalMatchedQuantity = totalMatched, sellPrice = actualSellPrice, // 使用实际成交价(如果查询失败则为下单价格) totalRealizedPnl = totalRealizedPnl, - priceUpdated = priceUpdated // 标记价格是否已更新 + priceUpdated = priceUpdated // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知) ) val savedRecord = sellMatchRecordRepository.save(matchRecord) @@ -1006,6 +1053,8 @@ open class CopyOrderTrackingService( val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) sellMatchDetailRepository.save(savedDetail) } + + logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}") } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt index f578dbd..adba5c9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt @@ -3,6 +3,7 @@ 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 @@ -11,6 +12,7 @@ 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 @@ -18,7 +20,7 @@ import java.math.BigDecimal /** * 订单状态更新服务 - * 定时轮询更新卖出订单的实际成交价,并清理已删除账户的订单 + * 定时轮询更新卖出订单的实际成交价,并更新买入订单的实际数据并发送通知 */ @Service class OrderStatusUpdateService( @@ -27,9 +29,11 @@ class OrderStatusUpdateService( 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 trackingService: CopyOrderTrackingService, + private val telegramNotificationService: TelegramNotificationService? ) { private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java) @@ -42,18 +46,21 @@ class OrderStatusUpdateService( } /** - * 定时更新卖出订单价格 + * 定时更新订单状态 * 每5秒执行一次 */ @Scheduled(fixedDelay = 5000) - fun updateSellOrderPrices() { + fun updateOrderStatus() { updateScope.launch { try { // 1. 清理已删除账户的订单 cleanupDeletedAccountOrders() - // 2. 更新卖出订单的实际成交价 + // 2. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段) updatePendingSellOrderPrices() + + // 3. 更新买入订单的实际数据并发送通知 + updatePendingBuyOrders() } catch (e: Exception) { logger.error("订单状态更新异常: ${e.message}", e) } @@ -125,11 +132,12 @@ class OrderStatusUpdateService( /** * 更新待更新的卖出订单价格 + * 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段) */ @Transactional private suspend fun updatePendingSellOrderPrices() { try { - // 查询所有价格未更新的卖出记录 + // 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理) val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse() if (pendingRecords.isEmpty()) { @@ -183,9 +191,20 @@ class OrderStatusUpdateService( account.walletAddress ) - // 如果 orderId 不是 0x 开头,直接标记为已更新(不需要通过API查询) + // 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送) if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) { - logger.debug("卖出订单ID非0x开头,直接标记为已更新: orderId=${record.sellOrderId}") + 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, @@ -197,7 +216,7 @@ class OrderStatusUpdateService( totalMatchedQuantity = record.totalMatchedQuantity, sellPrice = record.sellPrice, totalRealizedPnl = record.totalRealizedPnl, - priceUpdated = true, // 标记为已更新 + priceUpdated = true, // 标记为已处理(价格已更新和通知已发送) createdAt = record.createdAt ) sellMatchRecordRepository.save(updatedRecord) @@ -238,6 +257,18 @@ class OrderStatusUpdateService( 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( @@ -251,14 +282,25 @@ class OrderStatusUpdateService( totalMatchedQuantity = record.totalMatchedQuantity, sellPrice = actualSellPrice, // 更新卖出价格 totalRealizedPnl = totalRealizedPnl, // 更新总盈亏 - priceUpdated = true, // 标记为已更新 + priceUpdated = true, // 标记为已处理(价格已更新和通知已发送) createdAt = record.createdAt ) sellMatchRecordRepository.save(updatedRecord) - logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice") + 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, @@ -270,11 +312,11 @@ class OrderStatusUpdateService( totalMatchedQuantity = record.totalMatchedQuantity, sellPrice = record.sellPrice, totalRealizedPnl = record.totalRealizedPnl, - priceUpdated = true, // 标记为已更新 + priceUpdated = true, // 标记为已处理(价格已更新和通知已发送) createdAt = record.createdAt ) sellMatchRecordRepository.save(updatedRecord) - logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice") + logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice") } } catch (e: Exception) { logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e) @@ -285,5 +327,372 @@ class OrderStatusUpdateService( 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) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index 83655c1..5ad7a4a 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -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 × size(USDC) val amount = try { - val priceDecimal = price.toSafeBigDecimal() - val sizeDecimal = size.toSafeBigDecimal() + val priceDecimal = finalPrice.toSafeBigDecimal() + val sizeDecimal = finalSize.toSafeBigDecimal() priceDecimal.multiply(sizeDecimal).toString() } catch (e: Exception) { logger.warn("计算订单金额失败: ${e.message}", e) @@ -126,8 +136,8 @@ class TelegramNotificationService( marketSlug = marketSlug, side = actualSide, outcome = actualOutcome, - price = price, - size = size, + price = finalPrice, + size = finalSize, amount = amount, accountName = accountName, walletAddress = walletAddress, diff --git a/backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql b/backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql new file mode 100644 index 0000000..ffe9661 --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql @@ -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; + diff --git a/backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql b/backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql new file mode 100644 index 0000000..0fc44d3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql @@ -0,0 +1,7 @@ +-- 添加 Leader 买入数量字段,用于固定金额模式计算卖出比例 +ALTER TABLE copy_order_tracking +ADD COLUMN leader_buy_quantity DECIMAL(20, 8) DEFAULT NULL COMMENT 'Leader 买入数量(用于固定金额模式计算卖出比例)'; + +-- 对于已有数据,如果无法从 API 查询,设置为 NULL(不影响现有功能) +-- 新创建的记录会在创建时自动填充此字段 + diff --git a/docs/zh/DEPLOYMENT.md b/docs/zh/DEPLOYMENT.md index 067009e..f35ca62 100644 --- a/docs/zh/DEPLOYMENT.md +++ b/docs/zh/DEPLOYMENT.md @@ -129,7 +129,11 @@ docker-compose -f docker-compose.prod.yml logs -f ``` **注意事项**: -- ⚠️ 更新前建议备份数据库(如果使用 Docker Compose 中的 MySQL) +- ⚠️ **备份数据库(强烈推荐)**: + - 备份不是必须的,但强烈推荐,特别是生产环境 + - 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)