Compare commits

..

63 Commits

Author SHA1 Message Date
WrBug 8b73121c5d docs: 更新 RELEASE.md,添加 v1.1.5 版本说明
- 在 RELEASE.md 顶部添加 v1.1.5 release notes
- 删除临时文件:PR12_REVIEW.md、RELEASE_v1.1.4.md、RELEASE_v1.1.4_简版.md、RELEASE_v1.1.5.md
- 统一使用 RELEASE.md 管理所有版本发布说明
2026-01-04 13:07:08 +08:00
WrBug 95930332df refactor: 移除刷新代理钱包接口,增加copyRatio精度支持
- 移除刷新代理钱包相关接口和方法
  - AccountController: 移除 /refresh-proxy 和 /refresh-all-proxies 接口
  - AccountService: 移除 refreshProxyAddress 和 refreshAllProxyAddresses 方法

- 增加 copyRatio 字段精度支持
  - CopyTrading: copyRatio 精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
  - CopyTradingTemplate: copyRatio 精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
  - 新增数据库迁移脚本 V18__increase_copy_ratio_precision.sql

- 前端工具函数
  - 添加 formatNumber 函数用于格式化数字显示

修复的文件:
- AccountController.kt
- AccountService.kt
- CopyTrading.kt
- CopyTradingTemplate.kt
- frontend/src/utils/index.ts
- V18__increase_copy_ratio_precision.sql
2026-01-04 12:49:20 +08:00
WrBug 185cade11d fix: 修复按比例跟单金额精度问题,对金额进行向上取整处理
- 修复按比例计算的订单金额因精度问题导致低于最小限制的问题
- 对按比例计算的金额进行向上取整到2位小数(USDC精度)
- 如果向上取整后的金额仍低于最小限制,调整 buyQuantity 以满足最小限制
- 优化 BigDecimal.div 扩展函数,支持精度和舍入模式参数(带默认值)

修复的文件:
- MathExt.kt: 添加 div 扩展函数的重载版本,支持精度和舍入模式参数
- CopyOrderTrackingService.kt: 修复订单金额精度处理逻辑

问题:
订单金额 0.000999999860888892000000 因精度问题低于最小限制 1.00000000,
导致订单被错误跳过。

解决方案:
1. 对按比例计算的金额向上取整到2位小数
2. 如果向上取整后仍低于最小限制,调整数量以满足最小限制
3. 确保调整后的订单金额 >= minOrderSize
2026-01-04 12:46:35 +08:00
WrBug d9443e0d56 fix: 修复 InputNumber formatter 正则表达式错误,导致输入 10 变成 1 的问题
- 修复 copyRatio InputNumber 的 parser 函数,正确处理 % 符号和最小值检查
- 修复所有 formatter 函数中的正则表达式:将 /\.?0+$/ 改为 /\.0+$/
- 添加调试日志用于问题排查(可后续移除)

修复的文件:
- CopyTradingAdd.tsx
- CopyTradingEdit.tsx
- CopyTradingOrders/EditModal.tsx
- CopyTradingList.tsx
- TemplateList.tsx
- TemplateEdit.tsx
- TemplateAdd.tsx

问题原因:
错误的正则表达式 /\.?0+$/ 会匹配整数末尾的 0(如 "10" 的末尾 "0"),
导致 "10" 被错误替换为 "1"。
正确的正则表达式 /\.0+$/ 只匹配小数点后的尾随零。
2026-01-04 12:13:40 +08:00
WrBug a097376db7 fix: 移除 AccountList 中未使用的 navigate 导入,修复前端编译错误 2026-01-03 21:55:34 +08:00
WrBug 6e5ccdfe5c fix: 修复 walletType 字段问题和 WebSocket 内存泄漏,优化账户导入 UI
后端修复:
- 添加 walletType 字段到 Account 实体和数据库(V17 迁移)
- 修复 refreshProxyAddress 方法使用正确的 walletType
- 修复 WebSocketTicketService 内存泄漏(添加定时清理任务)

前端优化:
- 账户导入改为 Modal 弹窗,提升用户体验
- 默认钱包类型改为 Web3 Wallet(safe),并将其选项排在首位
- 更新多语言文本:将 MetaMask(浏览器钱包)改为 Web3钱包
- 修复 walletTypeHelp Tooltip 换行显示问题
2026-01-03 18:29:39 +08:00
WrBug 21da06d8b0 Merge pull request #12 from wry5560/feature/wallet-type-support
feat: 添加WebSocket票据认证和钱包类型支持
2026-01-03 17:57:27 +08:00
wry5560 5a83444b56 feat: 添加WebSocket票据认证和钱包类型支持
- 新增WebSocket票据服务,用于短期有效的WebSocket连接认证
- 支持Magic和Safe两种钱包类型,分别对应邮箱/OAuth登录和MetaMask用户
- 添加登录频率限制和安全防护
- 优化日志记录,屏蔽敏感信息
- 升级ethers.js到v6.16.0
- 新增多语言钱包类型说明
- 重构代理地址计算逻辑,支持CREATE2计算Magic代理地址
2026-01-03 12:27:59 +08:00
WrBug 591e678f73 Merge pull request #11 from WrBug/pre_release
Release v1.1.3: 添加 Telegram 通知功能
2026-01-02 05:13:45 +08:00
WrBug 1cfdcb88d3 feat: 优化 Telegram 通知功能
- 简化消息内容,仅保留标题、版本号等关键信息
- Docker 构建前发送构建中通知
- Docker 构建成功后添加部署文档链接
- PR 合并消息标题改为 'main 分支代码更新'
2026-01-02 05:12:24 +08:00
WrBug 2cc9cf82ba Merge pull request #10 from WrBug/pre_release
Release v1.1.3: 添加 Telegram 通知功能
2026-01-02 05:05:52 +08:00
WrBug 00f0898d98 fix: 修复 workflow YAML 语法错误,移除 heredoc 格式
- 将 Python 脚本的 heredoc 格式改为使用多个 echo 命令
- 避免 GitHub Actions YAML 解析器将 heredoc 误判为 YAML 语法
- 确保 workflow 文件符合 GitHub Actions 语法规范
2026-01-02 05:04:02 +08:00
WrBug 1de9b5e958 fix: PR 仅关闭时不发送 Telegram 通知
- 在发送通知步骤中添加 PR 合并状态检查
- 如果 PR 仅关闭(未合并),直接退出,不发送通知
- 确保只有合并到 main 分支的 PR 才会发送通知
2026-01-02 05:02:10 +08:00
WrBug c8e422a94b fix: 修复 workflow YAML 语法错误,移除 heredoc 格式
- 修复 docker-build.yml 和 telegram-notify.yml 中的 heredoc 格式问题
- 使用 $'\n' 格式构建多行字符串,避免 YAML 解析器误判
- 确保所有 workflow 文件符合 GitHub Actions YAML 语法规范
2026-01-02 04:58:14 +08:00
WrBug 60e9f9235d fix: 修复 workflow YAML 语法错误
- 修复 docker-build.yml 和 telegram-notify.yml 中的多行字符串格式问题
- 使用 heredoc 格式构建消息,避免 YAML 解析器误判
- 使用 $'\n' 格式追加内容,避免多行字符串导致 YAML 解析问题
2026-01-02 04:57:00 +08:00
WrBug 46baa416f4 fix: 修复 telegram-notify.yml YAML 语法错误
- 使用 heredoc 格式构建消息,避免 YAML 解析器误判
- 修复第 75 行的多行字符串格式问题
2026-01-02 04:52:50 +08:00
WrBug ecdb8af14a feat: 添加 Telegram 通知功能
- 添加 PR 合并到 main 分支时的 Telegram 通知
- 添加 Docker 镜像构建成功时的 Telegram 通知
- 支持 Markdown 转 HTML 格式
- 移除作者、仓库、变更统计、提交记录等冗余信息
2026-01-02 04:48:23 +08:00
WrBug d6027e48eb fix: 优化订单状态更新逻辑,避免误删订单和匹配明细
- 先检查 HTTP 状态码,非 200 的都跳过,不删除订单
- 只有当 HTTP 200 且响应体为 null 时,才表示订单不存在
- 保护已部分卖出的订单,如果已部分卖出则保留用于统计
- 避免因临时网络问题或 API 错误导致订单和 sell_match_detail 被误删
2026-01-02 04:13:10 +08:00
WrBug eb45013a93 Merge pull request #9 from WrBug/pre_release
fix: 修复前端编译错误(v1.1.2 后续修复)
2026-01-02 00:28:58 +08:00
WrBug f97bd5b9d9 docs: 更新 v1.1.2 发布说明,添加前端编译错误修复 2026-01-02 00:25:43 +08:00
WrBug df272156cf fix: 修复前端编译错误,移除不存在的 bestBid 属性引用 2026-01-02 00:24:31 +08:00
WrBug 502b4fb0b7 Merge pull request #8 from WrBug/pre_release
Release v1.1.2
2026-01-02 00:21:56 +08:00
WrBug 64f6a2b897 docs: 添加 v1.1.2 发布说明 2026-01-02 00:19:14 +08:00
WrBug f7f2411b9d 优化市场价格服务:移除降级查询逻辑,仅保留链上和订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 只保留链上 RPC 查询(市场结算结果)和 CLOB 订单簿查询
- 如果所有数据源都失败,抛出 IllegalStateException 异常
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 移除未使用的导入和方法(MarketResponse, JsonUtils, OutcomeResult 等)
- 所有调用方都已正确处理异常,无需额外修改
2026-01-02 00:14:42 +08:00
WrBug d96eb3e00a fix: 恢复 V1 migration 文件,避免 checksum 不匹配
保持 V1__init_database.sql 的原有内容不变,避免破坏已有数据库的 migration checksum。
failed_trade 表的删除将通过 V16 migration 处理。
2026-01-01 22:33:23 +08:00
WrBug cc1a732984 refactor: 移除下单失败存储数据库的功能
- 删除 FailedTrade 实体类和 FailedTradeRepository
- 从 CopyOrderTrackingService 中移除失败交易存储逻辑
- 移除 recordFailedTrade 方法
- 移除检查失败交易的代码
- 创建 Flyway migration V16 删除 failed_trade 表
- 从 V1__init_database.sql 中移除 failed_trade 表创建语句

下单失败时仅记录日志,不再存储到数据库
2026-01-01 22:24:47 +08:00
WrBug ec06003157 feat: 自动使用当前分支名作为 Docker 版本号
- deploy.sh 自动获取当前 Git 分支名作为版本号
- 分支名中的 / 自动替换为 -(Docker tag 不支持 /)
- docker-compose.yml 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 'dev'
2026-01-01 22:10:21 +08:00
WrBug 3d05b13298 fix: 修复内存泄漏问题,缓存和复用 Retrofit/OkHttpClient 实例
问题:
- 每次调用 createClobApi、createGammaApi 等方法都创建新的 OkHttpClient 和 Retrofit 实例
- OkHttpClient 包含连接池、线程池等资源,导致内存不断增长
- 运行几小时后内存从 400MB 涨到 1GB+

解决方案:
- 为不需要认证的 API 创建共享的 OkHttpClient 实例
- Gamma API、Data API、GitHub API、不带认证的 CLOB API 使用单例客户端
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存
- Builder Relayer API 按 relayerUrl 缓存
- 添加 @PreDestroy 方法清理缓存

效果:
- 大幅减少内存占用,避免内存泄漏
- 复用连接池和线程池,提高性能
- 内存占用将保持稳定,不再持续增长
2026-01-01 22:07:24 +08:00
WrBug fe2db11b75 其他代码修改
- CopyOrderTrackingRepository: 添加查询方法
- PolymarketClobService: 功能更新
- CopyOrderTrackingService: 逻辑优化
- OrderStatusUpdateService: 功能增强
- TelegramNotificationService: 通知优化
2026-01-01 12:03:05 +08:00
WrBug 5c18cbd95d 统一 Gson 使用,改为依赖注入方式
- 在 GsonConfig 中统一配置 Gson Bean(lenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- RetrofitFactory、BlockchainService、PolymarketApiKeyService 等统一使用注入的 Gson
- AccountService、PositionCheckService 添加 JsonUtils 注入
- OnChainWsUtils(object 单例)使用私有 Gson 实例(与 GsonConfig 配置一致)
- 移除所有 GsonConverterFactory.create() 无参调用,统一使用注入的 Gson
2026-01-01 12:01:17 +08:00
WrBug ad2fa4eef2 Merge pull request #7 from WrBug/pre_release
Release v1.1.1: 链上监听优化、市场查询优化和 Bug 修复
2025-12-28 05:13:18 +08:00
WrBug 8357546f3c docs: 更新 v1.1.1 release 文档,包含所有改动内容 2025-12-28 05:10:19 +08:00
WrBug a62141ea5e chore: 更新 RELEASE.md 为 v1.1.1 2025-12-28 05:04:45 +08:00
WrBug 9157203653 fix: 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
2025-12-28 05:04:27 +08:00
WrBug 23fc20763a fix: 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
2025-12-28 05:02:54 +08:00
WrBug e96a0b6279 feat: 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
  * 价格保留最多4位小数(截断,不四舍五入)
  * 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
  * 有账户名和钱包地址时显示:账户名(0x123...123)
  * 只有账户名时显示账户名
  * 只有钱包地址时显示脱敏后的地址
  * 都没有时显示未知账户
- 统一使用 buildAccountInfo 函数构建账户信息
- 应用到所有消息类型:订单成功、订单失败、订单被过滤、仓位赎回
2025-12-28 04:58:30 +08:00
WrBug 85d8619fe7 refactor: 统一发布说明文件,使用 RELEASE.md 替代版本化文件
- 创建 RELEASE.md,包含最新的 v1.1.0 发布说明
- 删除 RELEASE_v1.0.1.md 和 RELEASE_v1.1.0.md
- 仅保留最新的发布说明文件,简化维护
2025-12-28 04:53:47 +08:00
WrBug 89fb980da7 refactor: 移除 polygon.rpc.url 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除 application.properties 中的 polygon.rpc.url 配置
- 更新 ApiHealthCheckService 直接使用 RpcNodeService.getHttpUrl()
- 删除所有 Docker Compose 配置中的 POLYGON_RPC_URL 环境变量
- 删除所有部署脚本中的 POLYGON_RPC_URL 环境变量
- 更新所有文档,移除 POLYGON_RPC_URL 相关说明
- 删除 application.properties 中无用的 position.push 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot)

现在系统通过 RpcNodeService 从数据库读取 RPC 节点配置,用户可以通过系统设置页面管理 RPC 节点,不再需要环境变量配置。
2025-12-28 04:51:07 +08:00
WrBug 9f0b22fab5 feat: 优化市场状态查询和自动订单通知
- 使用 Retrofit 替代直接 HTTP 请求查询市场信息(通过 clob_token_ids)
- 实现链上查询市场结算结果(方案1):通过 ConditionalTokens 合约的 getCondition 方法实时获取市场结算状态
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_)不发送 Telegram 通知
- 添加 ABI 编码解析工具方法(decodeUint256Array、decodeConditionResult)
- 优化 PositionCheckService:优先使用链上查询,失败时降级到 API 查询
2025-12-28 00:02:29 +08:00
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 b5908a9bbf Merge pull request #6 from WrBug/pre_release
feat: 添加最大仓位限制配置功能 (v1.0.3)
2025-12-23 01:02:07 +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
WrBug f8d7276654 fix: 修复前端编译错误
- 移除未使用的导入 useParams 和 useSearchParams
- 移除未使用的导入 Card
- 移除不存在的统计字段:totalMatchedOrders, winRate, averagePnl, maxPnl, minPnl
- 移除未使用的 formatPercent 函数
2025-12-09 06:33:16 +08:00
WrBug 442d5778a7 Merge pull request #3 from WrBug/pre_release
Release v1.0.1: 优化前端UI和功能
2025-12-09 06:27:42 +08:00
WrBug cc41df2f24 feat: 优化前端UI和功能
- 修复仓位轮询检查逻辑,增加2分钟延迟判断
- 统一订单页面为Tab切换模式,使用Modal展示
- 优化移动端统计UI,改为两列线性布局
- 简化过滤类型标签,移除括号说明
- 账户管理列表优化:钱包地址显示前6后4,移除API凭证和活跃订单列
- 修复复制按钮功能,添加降级方案和Toast提示
- 修复菜单层级问题,订单下拉直接显示已成交和已过滤订单
- 添加编辑功能Modal化
- 完善多语言支持
2025-12-09 06:23:51 +08:00
WrBug cdadce467e fix: 修复仓位轮询检查逻辑和时区问题
- 修复仓位轮询检查:当仓位不存在时,只有当订单创建时间超过2分钟才认为仓位被出售,避免刚创建的订单被误判
- 修复时区问题:所有时间格式化使用系统时区,移除硬编码的时区配置
  - DateUtils 添加 formatDateTime() 函数,使用 ZoneId.systemDefault()
  - TelegramNotificationService 所有时间格式化使用 DateUtils.formatDateTime()
  - Dockerfile 和启动脚本移除时区配置,自动使用系统时区
2025-12-09 04:48:25 +08:00
WrBug 6e4d4f1ef5 fix: 修复仓位轮询检查逻辑,避免刚创建的订单被误判为已卖出
- 当仓位不存在时,只有当订单创建时间超过30秒才认为仓位被出售
- 避免因API延迟导致刚创建的订单被误判为已卖出状态
2025-12-09 04:23:23 +08:00
WrBug 9fb295a2f4 Merge pull request #2 from WrBug/feature_struct_refactor
Feature struct refactor
2025-12-09 01:34:36 +08:00
WrBug db16dea333 feat: 添加市场结算判断逻辑,支持赢了和输了的价格处理
- 添加市场已关闭时的结算判断逻辑
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
- 优先使用 outcomePrices(结算价格数组)判断
- 如果没有 outcomePrices,使用 bestBid 判断
- 支持多元市场的价格转换
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
2025-12-09 01:12:53 +08:00
WrBug d6f0914eeb fix: 修复健康检查路径不一致问题
- 修复 start.sh 中健康检查路径:从 /api/health 改为 /api/system/health
- 修复 Dockerfile 中健康检查路径:从 /api/health 改为 /api/system/health
- 确保健康检查路径与实际 HealthController 端点一致
2025-12-09 01:03:21 +08:00
WrBug f0c40533cf 优化跟单过滤逻辑和卖出价格计算
- 优化订单簿请求:仅在需要时请求,避免不必要的API调用
- 卖出价格改为市价卖出:优先使用订单簿bestBid,失败则使用Leader价格,固定按90%计算
- 价格容忍度默认值:如果为0,自动使用5%
- 使用枚举和数据类优化checkFilters方法:
  - 新增FilterResult数据类和FilterStatus枚举
  - 移除无用字段isBuyOrder参数
  - 使用类型安全的枚举替代字符串判断
- 优化代码结构:减少if-else嵌套,使用链式调用
- 移除最小订单簿深度功能(minOrderbookDepth)
- 优化最小订单深度逻辑:检查所有方向(买盘+卖盘)的总深度
2025-12-09 00:52:50 +08:00
WrBug d393bd7b40 fix: 修复未实现盈亏和持仓价值计算逻辑
- 修复同一市场多个订单时未实现盈亏重复计算的问题
- 按市场聚合订单,计算加权平均买入价格
- 修复持仓价值重复计算的问题
- 确保总盈亏和总盈亏百分比计算正确
2025-12-08 23:10:03 +08:00
WrBug 7173d24fe1 refactor: 重构代码结构,按业务分类组织 Controller 和 Service
- 重构 API 路径为 /api/业务/xxx 格式
- 将 Controller 和 Service 按业务分类组织:
  * copytrading: 跟单相关(configs, leaders, statistics, templates)
  * accounts: 账户管理(公共模块)
  * markets: 市场数据(公共模块)
  * system: 系统管理(users, notifications, proxy, health)
  * auth: 认证
  * announcement: 公告
- 将所有完整类名引用改为使用 import 导入
- 修复 calculatePositionValue 方法实现
- 更新前端 API 调用路径
2025-12-08 23:04:40 +08:00
154 changed files with 14085 additions and 2492 deletions
+89
View File
@@ -36,6 +36,49 @@ jobs:
echo "Extracted version: $VERSION"
echo "Full tag: $TAG_NAME"
- name: Send Telegram notification (build started)
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 通知失败不应该导致整个 job 失败
exit 0
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -65,3 +108,49 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
+103
View File
@@ -0,0 +1,103 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+8
View File
@@ -94,6 +94,13 @@ coverage/
test-results/
*.test.log
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Misc
*.bak
*.backup
@@ -102,4 +109,5 @@ test-results/
# Submodules and external dependencies
clob-client/
builder-relayer-client/
landing-page/
+21 -4
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
@@ -251,7 +270,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -318,7 +336,6 @@ cd frontend
| `DB_USERNAME` | 数据库用户名 | `root` |
| `DB_PASSWORD` | 数据库密码 | - |
| `SERVER_PORT` | 后端服务端口 | `8000` |
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` |
| `JWT_SECRET` | JWT 密钥 | - |
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
@@ -414,7 +431,7 @@ cd frontend
## 🔗 相关链接
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr)
- [Twitter](https://x.com/polyhermes)
- [Polymarket 官网](https://polymarket.com)
- [Polymarket API 文档](https://docs.polymarket.com)
+21 -4
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
@@ -251,7 +270,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -318,7 +336,6 @@ cd frontend
| `DB_USERNAME` | Database username | `root` |
| `DB_PASSWORD` | Database password | - |
| `SERVER_PORT` | Backend service port | `8000` |
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` |
| `JWT_SECRET` | JWT secret key | - |
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
@@ -414,7 +431,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
## 🔗 Related Links
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr)
- [Twitter](https://x.com/polyhermes)
- [Polymarket Official Website](https://polymarket.com)
- [Polymarket API Documentation](https://docs.polymarket.com)
+408
View File
@@ -0,0 +1,408 @@
# v1.1.5
## 🔧 功能优化与改进
### 前端优化
#### 优化 InputNumber 输入框格式化
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
- 影响范围:跟单配置、模板配置中的所有数值输入框
#### 优化数字显示格式
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100
- 统一所有数值输入框的显示格式,提升用户体验
### 后端优化
#### 优化按比例跟单金额计算逻辑
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
- 对订单金额进行向上取整处理(保留 2 位小数精度)
- 自动调整订单数量以满足最小限制要求
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
- 影响范围:按比例跟单的订单创建逻辑
- 技术细节:
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
-`CopyOrderTrackingService` 中优化金额计算和验证逻辑
#### 增强 copyRatio 精度支持
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%
- 影响的实体:CopyTrading、CopyTradingTemplate
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
## 🔧 功能优化
### 移除刷新代理钱包接口
- **移除接口**
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
- **影响范围**AccountController、AccountService
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
### 前端跟单比例配置优化
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
- **最大比例**:增加到 10000%,满足大比例跟单需求
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x"
- **输入验证**:增强输入验证,确保比例在合理范围内
## 📊 变更统计
- **提交数量**3 个提交
- **文件变更**15 个文件
- **代码变更**+575 行 / -194 行(净增加 381 行)
### 详细文件变更
**后端变更**
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
- `CopyTrading.kt` - 增加 copyRatio 精度
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
**前端变更**
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingList.tsx` - 优化比例显示格式
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
## 🔧 技术细节
### 数据库变更
- **迁移脚本**`V18__increase_copy_ratio_precision.sql`
- **变更内容**
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **移除接口**
- `POST /api/accounts/refresh-proxy`
- `POST /api/accounts/refresh-all-proxies`
- **无新增接口**
### 前端变更
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
- **显示优化**:跟单模式的比例显示为百分比格式
## 📝 升级说明
### 数据库升级
本次版本包含数据库迁移脚本,升级时会自动执行:
- 自动增加 `copy_ratio` 字段的精度
- 现有数据不受影响,精度升级是向后兼容的
### 配置变更
无需额外配置变更。
### 兼容性
- **向后兼容**:所有变更都是向后兼容的
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
- **数据兼容**:数据库字段精度升级不会影响现有数据
## 🎯 主要改进
1. **优化输入框格式化**:优化数值输入框的显示逻辑
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%
4. **代码清理**:移除不再使用的刷新代理钱包接口
## 🔗 相关链接
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
## 🙏 致谢
感谢所有贡献者和测试用户的反馈与支持!
---
# v1.1.2
## 🚀 主要功能
### 🐛 修复内存泄漏问题
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
### 📊 市场价格服务优化
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 如果所有数据源都失败,抛出明确的异常信息
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 简化代码逻辑,提高查询效率和准确性
### 🔧 代码架构优化
- 统一 Gson 使用,改为依赖注入方式
-`GsonConfig` 中统一配置 Gson Beanlenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
- 提高代码一致性和可维护性
### 🗑️ 功能清理
- 移除下单失败存储数据库的功能
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
-`CopyOrderTrackingService` 中移除失败交易存储逻辑
- 创建 Flyway migration V16 删除 `failed_trade`
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
### 🚀 部署优化
- 自动使用当前分支名作为 Docker 版本号
- 分支名中的 `/` 自动替换为 `-`Docker tag 不支持 `/
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
## 🐛 Bug 修复
### 修复 Flyway Migration 问题
- 恢复 V1 migration 文件,避免 checksum 不匹配
- 保持 `V1__init_database.sql` 的原有内容不变
- `failed_trade` 表的删除通过 V16 migration 处理
- 确保已有数据库的 migration checksum 保持一致
### 修复前端编译错误
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
## 📚 文档更新
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`
- 详细说明智能资金分析功能的使用方法和策略
## 🔧 技术改进
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
- 优化 `TelegramNotificationService`,改进通知逻辑
- 优化 `PositionCheckService`,简化代码结构
- 优化 `PolymarketClobService`,改进 API 调用逻辑
## 📦 数据库变更
- 删除 `failed_trade` 表(Migration: V16
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 29 个文件
- **代码变更**: +1597 行 / -678 行
- **主要提交**: 8 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.2
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
---
# v1.1.1
## 🚀 主要功能
### 🔗 链上 WebSocket 监听优化
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
### 📊 市场状态查询优化
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
- 如果链上查询失败,自动降级到 Gamma API 查询
- 提供更实时和准确的市场结算结果
### 🔕 自动订单通知优化
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
- 减少不必要的通知,提升用户体验
## 🐛 Bug 修复
### 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
## 🔧 功能优化
### 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
* 价格保留最多4位小数(截断,不四舍五入)
* 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
* 有账户名和钱包地址时显示:账户名(0x123...123)
* 只有账户名时显示账户名
* 只有钱包地址时显示脱敏后的地址
* 都没有时显示未知账户
### 配置优化
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除无用的 `position.push.polling-interval``position.push.heartbeat-timeout` 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
## 📚 文档更新
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
## 🔧 技术改进
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`
- 优化代码结构,减少代码重复,提高可维护性
---
# v1.1.0
## 🚀 主要功能
### 🔗 链上 WebSocket 实时监听
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
### 📊 RPC 节点管理
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
- 前端添加启用/禁用开关,支持实时切换节点状态
- 健康检查只检查启用的节点,提高检查效率
- 节点选择时自动过滤禁用的节点
### 💰 卖出订单价格轮询更新
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
- 清理已删除账户的订单记录
- 支持加权平均价格计算,处理部分成交的订单
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
## 🐛 Bug 修复
### 修复跟单卖出订单的 API 凭证解密问题
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
- 确保卖出订单能够正常使用 API 凭证进行认证
### 修复 SELL 订单精度问题
- 修复 SELL 订单的 `makerAmount``takerAmount` 精度问题:
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
- 确保订单能够正常提交到 Polymarket API
## 📚 文档更新
- 添加 Docker 版本更新说明(中英文)
- 添加链上 WebSocket 监听策略文档
- 添加跟单逻辑总结文档
- 更新部署文档,包含详细的版本更新步骤
## 🔧 技术改进
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
- 优化 WebSocket 连接管理,减少不必要的连接
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
- 启用 Spring 定时任务功能(`@EnableScheduling`
## 📦 数据库变更
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 32 个文件
- **代码变更**: +1872 行 / -1503 行
- **主要提交**: 7 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
+4 -4
View File
@@ -22,9 +22,9 @@ FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 安装必要的工具
# 安装必要的工具和时区数据
RUN apt-get update && \
apt-get install -y curl && \
apt-get install -y curl tzdata && \
rm -rf /var/lib/apt/lists/*
# 从构建阶段复制 JAR 文件
@@ -41,8 +41,8 @@ EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
CMD curl -f http://localhost:8000/api/system/health || exit 1
# 启动应用
# 启动应用(自动使用系统时区)
ENTRYPOINT ["java", "-jar", "app.jar"]
-3
View File
@@ -154,9 +154,6 @@ SPRING_PROFILES_ACTIVE=prod
# 服务器端口
SERVER_PORT=8000
# Polygon RPC
POLYGON_RPC_URL=https://polygon-rpc.com
# JWT 密钥(已自动生成随机值,生产环境建议修改)
JWT_SECRET=${JWT_SECRET}
-1
View File
@@ -14,7 +14,6 @@ services:
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
depends_on:
@@ -2,8 +2,10 @@ package com.wrbug.polymarketbot
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
class PolymarketBotApplication
fun main(args: Array<String>) {
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.api
import com.google.gson.JsonElement
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
@@ -29,10 +30,11 @@ data class JsonRpcRequest(
/**
* JSON-RPC 响应
* 使用 JsonElement 类型处理 result 字段,可以灵活处理字符串、对象、数组等类型
*/
data class JsonRpcResponse(
val jsonrpc: String? = null,
val result: String? = null,
val result: JsonElement? = null, // 使用 JsonElement 类型,可以处理任意 JSON 类型
val error: JsonRpcError? = null,
val id: Int? = null
)
@@ -227,7 +227,7 @@ data class CreateOrderRequest(
@Deprecated("使用 NewOrderRequest 代替")
data class CreateOrdersBatchRequest(
val orders: List<CreateOrderRequest>
val orders: List<NewOrderRequest>
)
data class CancelOrdersBatchRequest(
@@ -16,12 +16,14 @@ interface PolymarketGammaApi {
* 根据 condition ID 列表获取市场信息
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x..."
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
* @param includeTag 是否包含标签信息
* @return 市场信息数组
*/
@GET("/markets")
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
): Response<List<MarketResponse>>
}
@@ -51,6 +53,9 @@ data class MarketResponse(
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
val bestAsk: Double? = null,
// 以下字段可能存在于响应中,但不在标准文档中
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
)
@@ -0,0 +1,26 @@
package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Gson 配置类
* 统一配置 Gson 实例,使用 lenient 模式允许解析格式不严格的 JSON
*/
@Configuration
class GsonConfig {
/**
* 创建 Gson Bean
* 使用 lenient 模式,允许解析格式不严格的 JSON
*/
@Bean
fun gson(): Gson {
return GsonBuilder()
.setLenient()
.create()
}
}
@@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor(
handler: Any
): Boolean {
val path = request.requestURI
val method = request.method
// 只拦截POST请求
if (method != "POST") {
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
return true
}
// 排除不需要鉴权的路径
if (excludePaths.contains(path)) {
return true
}
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
// 允许 OPTIONS 请求(CORS 预检请求)
if (request.method == "OPTIONS") {
return true
}
@@ -1,6 +1,6 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.service.ProxyConfigService
import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.annotation.PostConstruct
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.createClient
import org.springframework.beans.factory.annotation.Value
@@ -18,7 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory
* - 账户 API Key 在调用时动态设置,不在此处配置
*/
@Configuration
class RetrofitConfig {
class RetrofitConfig(
private val gson: Gson
) {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
@@ -37,7 +40,7 @@ class RetrofitConfig {
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -21,10 +21,6 @@ class WebMvcConfig(
// 再注册JWT认证拦截器
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.repository.UserRepository
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import com.wrbug.polymarketbot.util.JwtUtils
import org.slf4j.LoggerFactory
import org.springframework.http.server.ServerHttpRequest
@@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor
/**
* WebSocket 握手拦截器
* 用于验证 JWT token
* 优先使用短期票据验证,其次使用 JWT token
*/
@Component
class WebSocketAuthInterceptor(
private val jwtUtils: JwtUtils,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val webSocketTicketService: WebSocketTicketService
) : HandshakeInterceptor {
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
@@ -27,22 +29,36 @@ class WebSocketAuthInterceptor(
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
// 从查询参数或请求头获取 token
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}")
// 优先使用票据验证(推荐方式,不暴露 JWT)
val ticket = getTicketFromRequest(request)
if (ticket != null) {
val username = webSocketTicketService.validateAndConsumeTicket(ticket)
if (username != null) {
attributes["username"] = username
logger.debug("WebSocket 连接票据认证成功: username=$username")
return true
}
logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容)
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证 token
if (!jwtUtils.validateToken(token)) {
logger.warn("WebSocket 连接 token 验证失败: ${request.uri}")
logger.warn("WebSocket 连接 token 验证失败")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证tokenVersion(检查token是否因密码修改而失效)
val username = jwtUtils.getUsernameFromToken(token)
if (username != null) {
@@ -50,21 +66,21 @@ class WebSocketAuthInterceptor(
if (user != null) {
val tokenVersion = jwtUtils.getTokenVersionFromToken(token)
if (tokenVersion == null || tokenVersion != user.tokenVersion) {
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}")
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
}
// 获取用户名并存入 attributes,供后续使用
attributes["username"] = username
logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}")
logger.debug("WebSocket 连接 JWT 认证成功: username=$username")
} else {
logger.warn("WebSocket 连接无法获取用户名: ${request.uri}")
logger.warn("WebSocket 连接无法获取用户名")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
return true
}
@@ -78,7 +94,22 @@ class WebSocketAuthInterceptor(
}
/**
* 从请求中获取 token
* 从请求中获取票据
*/
private fun getTicketFromRequest(request: ServerHttpRequest): String? {
val queryParams = request.uri.query ?: return null
val params = queryParams.split("&")
for (param in params) {
val parts = param.split("=", limit = 2)
if (parts.size == 2 && parts[0] == "ticket") {
return parts[1]
}
}
return null
}
/**
* 从请求中获取 token(兼容旧方式)
* 支持从查询参数 token 或请求头 Authorization 获取
*/
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
@@ -93,13 +124,13 @@ class WebSocketAuthInterceptor(
}
}
}
// 从请求头获取
val authHeader = request.headers.getFirst("Authorization")
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7)
}
return null
}
}
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
@@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
class WebSocketConfig(
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
private val webSocketAuthInterceptor: WebSocketAuthInterceptor
private val webSocketAuthInterceptor: WebSocketAuthInterceptor,
@Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String
) : WebSocketConfigurer {
/**
* 获取允许的 WebSocket 来源
* 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名
* 否则使用 setAllowedOriginPatterns 允许同源访问
*/
private fun getAllowedOrigins(): Array<String> {
return if (allowedOriginsConfig.isNotBlank()) {
allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray()
} else {
emptyArray()
}
}
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
val origins = getAllowedOrigins()
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
// 注意:此端点不需要鉴权,因为它只是转发外部数据
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
if (origins.isNotEmpty()) {
polymarketHandler.setAllowedOrigins(*origins)
} else {
// 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全
polymarketHandler.setAllowedOriginPatterns("*")
}
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
// 支持的频道:position(仓位推送)、order(订单推送,待实现)等
// 支持的频道:position(仓位推送)、order(订单推送)等
// 需要 JWT 鉴权
registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor)
if (origins.isNotEmpty()) {
unifiedHandler.setAllowedOrigins(*origins)
} else {
unifiedHandler.setAllowedOriginPatterns("*")
}
}
}
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.accounts
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AccountService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import java.math.BigDecimal
* 账户管理控制器
*/
@RestController
@RequestMapping("/api/copy-trading/accounts")
@RequestMapping("/api/accounts")
class AccountController(
private val accountService: AccountService,
private val messageSource: MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.announcement
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AnnouncementService
import com.wrbug.polymarketbot.service.announcement.AnnouncementService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,8 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.auth
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AuthService
import com.wrbug.polymarketbot.service.auth.AuthService
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.*
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService,
private val messageSource: MessageSource
private val messageSource: MessageSource,
private val webSocketTicketService: WebSocketTicketService
) {
private val logger = LoggerFactory.getLogger(AuthController::class.java)
@@ -25,7 +27,10 @@ class AuthController(
* 登录接口
*/
@PostMapping("/login")
fun login(@RequestBody request: LoginRequest): ResponseEntity<ApiResponse<LoginResponse>> {
fun login(
@RequestBody request: LoginRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<LoginResponse>> {
return try {
if (request.username.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource))
@@ -33,15 +38,19 @@ class AuthController(
if (request.password.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource))
}
val result = authService.login(request.username, request.password)
val ipAddress = getClientIpAddress(httpRequest)
val result = authService.login(request.username, request.password, ipAddress)
result.fold(
onSuccess = { loginResponse ->
ResponseEntity.ok(ApiResponse.success(loginResponse))
},
onFailure = { e ->
logger.error("登录失败: ${e.message}", e)
when (e) {
is IllegalStateException -> {
// 限速或锁定错误
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource))
}
is IllegalArgumentException -> {
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource))
@@ -49,15 +58,36 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
}
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
}
}
)
} catch (e: Exception) {
logger.error("登录异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
}
}
/**
* 获取客户端IP地址
*/
private fun getClientIpAddress(request: HttpServletRequest): String {
var ip = request.getHeader("X-Forwarded-For")
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("X-Real-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("Proxy-Client-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.remoteAddr
}
// 处理多个IP的情况
if (ip.contains(",")) {
ip = ip.split(",")[0].trim()
}
return ip
}
/**
* 重置密码接口
@@ -132,5 +162,27 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource))
}
}
/**
* 获取 WebSocket 连接票据
* 返回一个短期有效30的一次性票据用于 WebSocket 连接认证
* 避免在 WebSocket URL 中暴露 JWT
*/
@PostMapping("/ws-ticket")
fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<WebSocketTicketResponse>> {
return try {
// 从请求属性中获取用户名(由 JWT 拦截器设置)
val username = httpRequest.getAttribute("username") as? String
if (username == null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
}
val ticket = webSocketTicketService.generateTicket(username)
ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket)))
} catch (e: Exception) {
logger.error("获取 WebSocket 票据异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
}
}
}
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.configs
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingService
import com.wrbug.polymarketbot.service.FilteredOrderService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
* 跟单配置管理控制器钱包-模板关联
*/
@RestController
@RequestMapping("/api/copy-trading")
@RequestMapping("/api/copy-trading/configs")
class CopyTradingController(
private val copyTradingService: CopyTradingService,
private val filteredOrderService: FilteredOrderService,
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.LeaderService
import com.wrbug.polymarketbot.service.copytrading.leaders.LeaderService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.statistics
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingStatisticsService
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyTradingStatisticsService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.copytrading.templates
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingTemplateService
import com.wrbug.polymarketbot.service.copytrading.templates.CopyTradingTemplateService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -1,11 +1,13 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.markets
import com.wrbug.polymarketbot.api.LatestPriceResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AccountService
import com.wrbug.polymarketbot.service.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -16,18 +18,20 @@ import org.springframework.web.bind.annotation.*
* 提供市场相关的数据查询接口价格订单簿等
*/
@RestController
@RequestMapping("/api/copy-trading/markets")
@RequestMapping("/api/markets")
class MarketController(
private val accountService: AccountService,
private val clobService: PolymarketClobService,
private val marketPriceService: MarketPriceService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(MarketController::class.java)
/**
* 获取市场价格通过 Gamma API
* 使用 Gamma API 获取价格信息因为 Gamma API 支持 condition_ids 参数
* 获取市场价格
* 使用 MarketPriceService 获取当前市场价格支持多数据源降级
* 返回当前价格前端接收后自行填充到 bestBid 字段
*/
@PostMapping("/price")
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
@@ -36,16 +40,16 @@ class MarketController(
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
}
val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("获取市场价格失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
}
val outcomeIndex = request.outcomeIndex ?: 0
val price = runBlocking {
marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex)
}
val response = MarketPriceResponse(
marketId = request.marketId,
currentPrice = price.toString()
)
ResponseEntity.ok(ApiResponse.success(response))
} catch (e: Exception) {
logger.error("获取市场价格异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
@@ -11,7 +11,7 @@ import java.util.*
* 用于 Docker 健康检查和监控
*/
@RestController
@RequestMapping("/api/health")
@RequestMapping("/api/system/health")
class HealthController {
@GetMapping
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.NotificationConfigService
import com.wrbug.polymarketbot.service.TelegramNotificationService
import com.wrbug.polymarketbot.service.system.NotificationConfigService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*
* 消息推送配置控制器
*/
@RestController
@RequestMapping("/api/notifications")
@RequestMapping("/api/system/notifications")
class NotificationController(
private val notificationConfigService: NotificationConfigService,
private val telegramNotificationService: TelegramNotificationService,
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.ApiHealthCheckService
import com.wrbug.polymarketbot.service.ProxyConfigService
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*
* 代理配置控制器
*/
@RestController
@RequestMapping("/api/proxy-config")
@RequestMapping("/api/system/proxy")
class ProxyConfigController(
private val proxyConfigService: ProxyConfigService,
private val apiHealthCheckService: ApiHealthCheckService,
@@ -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
)
}
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.SystemConfigService
import com.wrbug.polymarketbot.service.RelayClientService
import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -1,8 +1,8 @@
package com.wrbug.polymarketbot.controller
package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.UserService
import com.wrbug.polymarketbot.service.system.UserService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
* 用户管理控制器
*/
@RestController
@RequestMapping("/api/users")
@RequestMapping("/api/system/users")
class UserController(
private val userService: UserService,
private val messageSource: MessageSource
@@ -7,7 +7,8 @@ data class AccountImportRequest(
val privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null,
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
@@ -36,6 +37,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 // 自动赎回(系统级别配置,默认开启)
)
@@ -69,6 +73,7 @@ data class AccountDto(
val proxyAddress: String, // Polymarket 代理钱包地址
val accountName: String?,
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
@@ -192,14 +197,11 @@ data class LatestPriceRequest(
)
/**
* 市场价格响应
* 市场当前价格响应
*/
data class MarketPriceResponse(
val marketId: String,
val lastPrice: String?, // 最新成交价
val bestBid: String?, // 最优买价(用于卖出参考)
val bestAsk: String?, // 最优卖价(用于买入参考)
val midpoint: String? // 中间价
val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级)
)
/**
@@ -14,3 +14,10 @@ data class CheckFirstUseResponse(
val isFirstUse: Boolean
)
/**
* WebSocket 票据响应
*/
data class WebSocketTicketResponse(
val ticket: String
)
@@ -32,9 +32,11 @@ data class CopyTradingCreateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),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 // 推送失败订单(可选)
@@ -64,9 +66,11 @@ data class CopyTradingUpdateRequest(
// 过滤条件
val minOrderDepth: String? = null,
val maxSpread: String? = null,
val minOrderbookDepth: 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,9 +137,11 @@ data class CopyTradingDto(
// 过滤条件
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: 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, // 推送失败订单(默认关闭)
@@ -22,7 +22,6 @@ data class TemplateCreateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
@@ -50,7 +49,6 @@ data class TemplateUpdateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
@@ -85,7 +83,6 @@ data class TemplateCopyRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
@@ -120,7 +117,6 @@ data class TemplateDto(
// 过滤条件
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val createdAt: Long,
@@ -40,6 +40,9 @@ data class Account(
@Column(name = "is_enabled", nullable = false)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
@Column(name = "wallet_type", nullable = false, length = 20)
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -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(),
@@ -32,7 +32,7 @@ data class CopyTrading(
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
@@ -78,15 +78,19 @@ data class CopyTrading(
@Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@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, // 配置名(可选)
@@ -20,7 +20,7 @@ data class CopyTradingTemplate(
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
@@ -66,9 +66,6 @@ data class CopyTradingTemplate(
@Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@@ -1,55 +0,0 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 失败交易实体
* 记录处理失败的交易信息
*/
@Entity
@Table(name = "failed_trade")
data class FailedTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Column(name = "leader_trade_id", nullable = false, length = 100)
val leaderTradeId: String, // Leader 的交易ID
@Column(name = "trade_type", nullable = false, length = 10)
val tradeType: String, // BUY 或 SELL
@Column(name = "copy_trading_id", nullable = false)
val copyTradingId: Long,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "side", nullable = false, length = 10)
val side: String, // YES/NO
@Column(name = "price", nullable = false, length = 50)
val price: String, // 价格(字符串格式)
@Column(name = "size", nullable = false, length = 50)
val size: String, // 数量(字符串格式)
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null, // 错误信息
@Column(name = "retry_count", nullable = false)
val retryCount: Int = 0, // 重试次数
@Column(name = "failed_at", nullable = false)
val failedAt: Long = System.currentTimeMillis(),
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -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,16 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
* 根据买入订单ID查询订单跟踪记录
*/
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
/**
* 查询未发送通知的买入订单(用于轮询更新)
*/
fun findByNotificationSentFalse(): List<CopyOrderTracking>
/**
* 查询指定时间之前创建的订单(用于检查30秒后未成交的订单)
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
}
@@ -1,23 +0,0 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.FailedTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* 失败交易Repository
*/
@Repository
interface FailedTradeRepository : JpaRepository<FailedTrade, Long> {
/**
* 根据Leader ID和交易ID查询
*/
fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade?
/**
* 检查是否存在失败的交易
*/
fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean
}
@@ -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>
}
@@ -1,213 +0,0 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.OrderbookResponse
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 跟单过滤条件检查服务
*/
@Service
class CopyTradingFilterService(
private val clobService: PolymarketClobService
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
/**
* 检查过滤条件
* @param copyTrading 跟单配置
* @param tokenId token ID(用于获取订单簿)
* @param isBuyOrder 是否为买入订单(true=买入,false=卖出)
* @return Pair<是否通过, 失败原因>
*/
suspend fun checkFilters(
copyTrading: CopyTrading,
tokenId: String,
isBuyOrder: Boolean,
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
): Pair<Boolean, String> {
// 1. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.first) {
return priceRangeCheck
}
}
// 2. 价格合理性检查(基础检查,无需配置)
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败
// 3. 获取订单簿
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
return Pair(false, "获取订单簿失败: ${error?.message ?: "未知错误"}")
}
val orderbook = orderbookResult.getOrNull()
if (orderbook == null) {
return Pair(false, "订单簿为空")
}
// 4. 买一卖一价差过滤
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.first) {
return spreadCheck
}
// 5. 订单深度过滤
val depthCheck = checkOrderDepth(copyTrading, orderbook, isBuyOrder)
if (!depthCheck.first) {
return depthCheck
}
// 6. 最小订单簿深度过滤(可选)
val orderbookDepthCheck = checkOrderbookDepth(copyTrading, orderbook, isBuyOrder)
if (!orderbookDepthCheck.first) {
return orderbookDepthCheck
}
return Pair(true, "")
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
* @param tradePrice Leader 交易价格
* @return Pair<是否通过, 失败原因>
*/
private fun checkPriceRange(
copyTrading: CopyTrading,
tradePrice: BigDecimal
): Pair<Boolean, String> {
// 如果未配置价格区间,直接通过
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
return Pair(true, "")
}
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return Pair(false, "价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return Pair(false, "价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
}
return Pair(true, "")
}
/**
* 检查买一卖一价差
* bestBid: 买盘中的最高价格(最大值)
* bestAsk: 卖盘中的最低价格(最小值)
*/
private fun checkSpread(
copyTrading: CopyTrading,
orderbook: OrderbookResponse
): Pair<Boolean, String> {
// 如果未启用价差过滤,直接通过
if (copyTrading.maxSpread == null) {
return Pair(true, "")
}
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
// 获取卖盘中的最低价格(bestAsk = asks 中的最小值)
val bestAsk = orderbook.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestBid == null || bestAsk == null) {
return Pair(false, "订单簿缺少买一或卖一价格")
}
// 计算价差(绝对价格)
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return Pair(false, "价差过大: $spread > ${copyTrading.maxSpread}")
}
return Pair(true, "")
}
/**
* 检查订单深度
*/
private fun checkOrderDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse,
isBuyOrder: Boolean
): Pair<Boolean, String> {
// 如果未启用订单深度过滤,直接通过
if (copyTrading.minOrderDepth == null) {
return Pair(true, "")
}
// 对于买入订单,检查卖盘(asks)深度
// 对于卖出订单,检查买盘(bids)深度
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
// 计算总深度(累计订单金额)
var totalDepth = BigDecimal.ZERO
for (order in orders) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
totalDepth = totalDepth.add(orderAmount)
}
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return Pair(false, "订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}")
}
return Pair(true, "")
}
/**
* 检查最小订单簿深度(前 N 档深度)
*/
private fun checkOrderbookDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse,
isBuyOrder: Boolean
): Pair<Boolean, String> {
// 如果未启用最小订单簿深度过滤,直接通过
if (copyTrading.minOrderbookDepth == null) {
return Pair(true, "")
}
// 对于买入订单,检查卖盘(asks)前 3 档深度
// 对于卖出订单,检查买盘(bids)前 3 档深度
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
val topNOrders = orders.take(3) // 前 3 档
// 计算前 N 档总深度
var totalDepth = BigDecimal.ZERO
for (order in topNOrders) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
totalDepth = totalDepth.add(orderAmount)
}
if (totalDepth.lt(copyTrading.minOrderbookDepth)) {
return Pair(false, "订单簿深度不足: $totalDepth < ${copyTrading.minOrderbookDepth}")
}
return Pair(true, "")
}
}
@@ -1,115 +0,0 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
/**
* 跟单监听服务(主服务)
* 管理所有Leader的交易监听(使用轮询方式)
* 注意:WebSocket 需要认证才能订阅其他用户的交易,因此只使用轮询方式
*/
@Service
class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val pollingService: CopyTradingPollingService
) {
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
/**
* 系统启动时初始化监听
*/
@PostConstruct
fun init() {
scope.launch {
try {
startMonitoring()
} catch (e: Exception) {
logger.error("启动跟单监听失败", e)
}
}
}
/**
* 系统关闭时清理资源
*/
@PreDestroy
fun destroy() {
scope.cancel()
// 只使用轮询,不使用WebSocket
pollingService.stop()
}
/**
* 启动监听
*/
suspend fun startMonitoring() {
// 1. 获取所有启用的跟单关系
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
if (enabledCopyTradings.isEmpty()) {
return
}
// 2. 获取所有需要监听的Leader(去重)
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
val leaders = leaderIds.mapNotNull { leaderId ->
leaderRepository.findById(leaderId).orElse(null)
}
// 3. 启动轮询监听(使用 /activity 接口,不需要认证)
// 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询
pollingService.start(leaders)
}
/**
* 添加Leader监听(当创建新的跟单关系时调用)
*/
suspend fun addLeaderMonitoring(leaderId: Long) {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isEmpty()) {
return
}
// 只使用轮询,不使用WebSocket(需要认证)
pollingService.addLeader(leader)
}
/**
* 移除Leader监听(当删除跟单关系时调用)
*/
suspend fun removeLeaderMonitoring(leaderId: Long) {
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isNotEmpty()) {
return
}
// 只使用轮询,不使用WebSocket
pollingService.removeLeader(leaderId)
}
/**
* 重新启动监听(当跟单关系状态改变时调用)
*/
suspend fun restartMonitoring() {
// 只使用轮询,不使用WebSocket
pollingService.stop()
delay(1000) // 等待1秒
startMonitoring()
}
}
@@ -1,52 +0,0 @@
package com.wrbug.polymarketbot.service
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.atomic.AtomicReference
/**
* 频率限制服务(使用内存缓存,全局限制)
*/
@Service
class RateLimitService {
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
@Value("\${rate-limit.reset-password.max-attempts:3}")
private var maxAttempts: Int = 3
@Value("\${rate-limit.reset-password.window-seconds:60}")
private var windowSeconds: Long = 60
// 全局尝试记录列表(时间戳),所有请求共享
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
/**
* 检查重置密码频率限制(全局限制,不按IP)
* @return Result,如果超过限制则返回失败
*/
fun checkResetPasswordRateLimit(): Result<Unit> {
val now = System.currentTimeMillis()
val windowStart = now - (windowSeconds * 1000)
// 获取当前尝试记录列表
val attempts = resetPasswordAttempts.get()
// 清理过期记录(超过时间窗口的记录)
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
// 检查是否超过限制
if (validAttempts.size >= maxAttempts) {
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts")
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试"))
}
// 记录本次尝试
validAttempts.add(now)
resetPasswordAttempts.set(validAttempts)
return Result.success(Unit)
}
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
@@ -8,6 +8,14 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.util.CryptoUtils
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -27,9 +35,10 @@ class AccountService(
private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val cryptoUtils: CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService
private val relayClientService: RelayClientService,
private val jsonUtils: JsonUtils
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -90,8 +99,9 @@ class AccountService(
}
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -141,6 +151,7 @@ class AccountService(
accountName = accountName,
isDefault = false, // 不再支持默认账户
isEnabled = request.isEnabled,
walletType = request.walletType, // 保存钱包类型
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
@@ -354,6 +365,7 @@ class AccountService(
proxyAddress = account.proxyAddress,
accountName = account.accountName,
isEnabled = account.isEnabled,
walletType = account.walletType,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
@@ -919,6 +931,8 @@ class AccountService(
marketId = request.marketId,
marketSlug = marketSlug,
side = request.side,
price = sellPrice, // 直接传递卖出价格
size = sellQuantity.toPlainString(), // 直接传递卖出数量
accountName = account.accountName,
walletAddress = account.walletAddress,
clobApi = clobApi,
@@ -1070,7 +1084,7 @@ class AccountService(
/**
* 从订单表获取最优价用于市价单
* 支持多元市场二元三元及以上
* 委托给 PolymarketClobService.getOptimalPrice 方法
* 委托给 com.wrbug.polymarketbot.service.common.PolymarketClobService.getOptimalPrice 方法
*
* @param tokenId token ID通过 marketId outcomeIndex 计算得出
* @param isSellOrder 是否为卖出订单true: 卖单需要 bestBidfalse: 买单需要 bestAsk
@@ -1111,7 +1125,7 @@ class AccountService(
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
// 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格
if (outcomeIndex != null && outcomeIndex > 0) {
val outcomes = JsonUtils.parseStringArray(market.outcomes)
val outcomes = jsonUtils.parseStringArray(market.outcomes)
// 只对二元市场进行价格转换
if (outcomes.size == 2) {
// 保存原始第一个 outcome 的价格
@@ -1143,13 +1157,13 @@ class AccountService(
null
}
// 优先使用 lastPrice(最近成交价),如果没有则使用 bestBid,最后使用 midpoint
val currentPrice = lastPrice ?: bestBid ?: midpoint ?: "0"
Result.success(
MarketPriceResponse(
marketId = marketId,
lastPrice = lastPrice,
bestBid = bestBid,
bestAsk = bestAsk,
midpoint = midpoint
currentPrice = currentPrice
)
)
} else {
@@ -1167,7 +1181,7 @@ class AccountService(
/**
* 获取可赎回仓位统计
*/
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<com.wrbug.polymarketbot.dto.RedeemablePositionsSummary> {
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<RedeemablePositionsSummary> {
return try {
val positionsResult = getAllPositions()
positionsResult.fold(
@@ -1223,7 +1237,7 @@ class AccountService(
* 赎回仓位
* 支持多账户多仓位赎回自动按账户和市场分组
*/
suspend fun redeemPositions(request: com.wrbug.polymarketbot.dto.PositionRedeemRequest): Result<com.wrbug.polymarketbot.dto.PositionRedeemResponse> {
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.dto.AccountPositionDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking
@@ -18,6 +18,10 @@ import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
@@ -39,7 +43,8 @@ class PositionCheckService(
private val relayClientService: RelayClientService,
private val telegramNotificationService: TelegramNotificationService?,
private val accountRepository: AccountRepository,
private val messageSource: MessageSource
private val messageSource: MessageSource,
private val marketPriceService: MarketPriceService
) {
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
@@ -186,7 +191,7 @@ class PositionCheckService(
/**
* 逻辑1处理待赎回仓位
* 按照以下逻辑处理
https://clob.polymarket.com * 按照以下逻辑处理
* 1. 无待赎回仓位跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位发送通知事件
* 3. (已配置) && 有待赎回的仓位处理订单逻辑
@@ -366,17 +371,51 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) {
// 仓位不存在,更新所有订单状态为已卖出
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
// 仓位不存在,检查订单创建时间
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
val now = System.currentTimeMillis()
val ordersToMarkAsSold = orders.filter { order ->
val orderAge = now - order.createdAt
orderAge > 120000 // 2分钟 = 120000毫秒
}
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
}
} else {
// 有仓位,按订单下单顺序(FIFO)更新状态
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 计算逻辑:
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
val positionQuantity = position.quantity.toSafeBigDecimal()
// 计算总订单数量
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
sum.add(order.remainingQuantity.toSafeBigDecimal())
}
// 计算已成交数量
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
if (soldQuantity <= BigDecimal.ZERO) {
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
continue
}
// 如果已成交数量 > 0,按FIFO顺序匹配订单
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
copyTrading.id, marketId, outcomeIndex)
}
}
}
@@ -387,25 +426,13 @@ class PositionCheckService(
/**
* 获取当前市场最新价用于更新订单卖出价
* 优先使用 bestBid最优买价如果没有则使用 midpoint中间价
* 委托给 MarketPriceService 处理
*/
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
return try {
val priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
val marketPrice = priceResult.getOrNull()
if (marketPrice != null) {
// 优先使用 bestBid(最优买价,用于卖出参考),如果没有则使用 midpoint
val priceStr = marketPrice.bestBid ?: marketPrice.midpoint ?: marketPrice.lastPrice
priceStr?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
} catch (e: Exception) {
logger.error("获取市场最新价失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
BigDecimal.ZERO
}
return marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex)
}
/**
* 在仓位赎回成功后更新订单状态为已卖出
* 使用卖出逻辑更新所有订单状态未卖出订单的
@@ -492,7 +519,8 @@ class PositionCheckService(
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
totalRealizedPnl = totalRealizedPnl,
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
@@ -512,12 +540,22 @@ class PositionCheckService(
/**
* FIFO 顺序更新订单状态为已卖出
* 仓位数量小于订单数量总和时按订单下单顺序更新
* 同时创建卖出记录和匹配明细用于统计
* @param orders 订单列表已按创建时间排序FIFO
* @param soldQuantity 已成交数量总订单数量 - 仓位数量
* @param sellPrice 卖出价格
* @param copyTradingId 跟单配置ID
* @param marketId 市场ID
* @param outcomeIndex 结果索引
*
* 逻辑说明
* 1. 按订单创建时间顺序FIFO处理
* 2. 如果订单剩余数量 <= 已成交数量订单完全成交
* 3. 如果订单剩余数量 > 已成交数量订单部分成交
* 4. 同时创建卖出记录和匹配明细用于统计
*/
private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>,
availableQuantity: BigDecimal,
soldQuantity: BigDecimal,
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
@@ -529,7 +567,7 @@ class PositionCheckService(
try {
// 订单已经按 createdAt ASC 排序(FIFO
var remaining = availableQuantity
var remaining = soldQuantity
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>()
@@ -596,7 +634,8 @@ class PositionCheckService(
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
totalRealizedPnl = totalRealizedPnl,
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.dto.PositionListResponse
import jakarta.annotation.PostConstruct
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.dto.AccountPositionDto
import com.wrbug.polymarketbot.dto.PositionListResponse
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessageType
import com.wrbug.polymarketbot.dto.getPositionKey
@@ -155,7 +156,7 @@ class PositionPushService(
* 处理仓位更新事件
* 根据文档要求每次轮训完成后向订阅者发送全量数据
*/
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
private fun handlePositionUpdate(positions: PositionListResponse) {
// 更新快照
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.announcement
import com.wrbug.polymarketbot.api.GitHubApi
import com.wrbug.polymarketbot.api.GitHubCommentResponse
import com.wrbug.polymarketbot.dto.AnnouncementDto
import com.wrbug.polymarketbot.dto.AnnouncementListResponse
import com.wrbug.polymarketbot.util.RetrofitFactory
@@ -25,30 +26,30 @@ class AnnouncementService(
@Value("\${github.announcement.issue.number:1}")
private val issueNumber: Int
) {
private val logger = LoggerFactory.getLogger(AnnouncementService::class.java)
// GitHub API 客户端(懒加载)
private val githubApi: GitHubApi by lazy {
retrofitFactory.createGitHubApi()
}
// 需要排除的 Issue ID(从 issue_url 中提取)
private val excludedIssueId = "3703128976"
// 缓存数据(1分钟有效期)
private data class CachedData<T>(
val data: T,
val timestamp: Long
)
private var cachedList: CachedData<AnnouncementListResponse>? = null
private var cachedAssignees: CachedData<List<String>>? = null
private var cachedComments: CachedData<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>>? = null
// 缓存有效期:10分钟(毫秒)
private val cacheExpiryTime = 10 * 60 * 1000L
/**
* 检查缓存是否有效
*/
@@ -57,7 +58,7 @@ class AnnouncementService(
val now = System.currentTimeMillis()
return (now - cached.timestamp) < cacheExpiryTime
}
/**
* 检查是否被限流
*/
@@ -70,7 +71,7 @@ class AnnouncementService(
val remaining = response.headers()["X-RateLimit-Remaining"]
return remaining == "0"
}
/**
* 获取 Issue assignees 列表通过 API 获取带缓存
* @return Pair<assignees列表, 是否使用了缓存>
@@ -81,14 +82,14 @@ class AnnouncementService(
logger.debug("使用缓存的 assignees")
return Pair(cachedAssignees!!.data, true)
}
return try {
val response = githubApi.getIssue(
owner = repoOwner,
repo = repoName,
issueNumber = issueNumber
)
// 如果被限流,使用缓存数据,不更新缓存
if (isRateLimited(response)) {
logger.warn("GitHub API 被限流,使用缓存的 assignees(不更新缓存)")
@@ -98,14 +99,14 @@ class AnnouncementService(
// 如果没有缓存,使用默认值
return Pair(listOf("WrBug"), false)
}
val assignees = if (response.isSuccessful && response.body() != null) {
response.body()!!.assignees.map { it.login }
} else {
logger.warn("获取 Issue assignees 失败,使用默认值: code=${response.code()}")
listOf("WrBug") // 默认值
}
// 更新缓存
cachedAssignees = CachedData(assignees, System.currentTimeMillis())
Pair(assignees, false) // 返回新数据,标记为未使用缓存
@@ -119,24 +120,24 @@ class AnnouncementService(
Pair(listOf("WrBug"), false) // 默认值
}
}
/**
* 获取 Issue 评论列表带缓存
* @return Pair<评论列表, 是否使用了缓存>
*/
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>, Boolean> {
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<GitHubCommentResponse>, Boolean> {
// 检查缓存
if (!forceRefresh && isCacheValid(cachedComments)) {
logger.debug("使用缓存的评论列表")
return Pair(cachedComments!!.data, true)
}
val response = githubApi.getIssueComments(
owner = repoOwner,
repo = repoName,
issueNumber = issueNumber
)
// 如果被限流,使用缓存数据,不更新缓存
if (isRateLimited(response)) {
logger.warn("GitHub API 被限流,使用缓存的评论列表(不更新缓存)")
@@ -146,7 +147,7 @@ class AnnouncementService(
// 如果没有缓存,抛出异常
throw Exception("获取公告列表失败: GitHub API 被限流,且无缓存数据")
}
if (!response.isSuccessful || response.body() == null) {
logger.error("获取 GitHub Issue 评论失败: code=${response.code()}, message=${response.message()}")
// 如果缓存存在,使用缓存
@@ -156,14 +157,14 @@ class AnnouncementService(
}
throw Exception("获取公告列表失败: HTTP ${response.code()}")
}
val comments = response.body()!!
// 更新缓存
cachedComments = CachedData(comments, System.currentTimeMillis())
return Pair(comments, false) // 返回新数据,标记为未使用缓存
}
/**
* 获取公告列表最近10条
* @param forceRefresh 是否强制刷新缓存
@@ -174,12 +175,12 @@ class AnnouncementService(
logger.debug("使用缓存的公告列表")
return Result.success(cachedList!!.data)
}
return try {
// 强制刷新时,先尝试获取新数据
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
// 如果强制刷新时使用了缓存(被限流),直接返回缓存数据,不更新缓存
if (forceRefresh && (assigneesFromCache || commentsFromCache)) {
logger.warn("强制刷新时被限流,返回缓存的公告列表(不更新缓存)")
@@ -187,48 +188,47 @@ class AnnouncementService(
return Result.success(cachedList!!.data)
}
}
// 筛选条件:
// 1. assignees 发布的评论
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
val filteredComments = comments
.filter { comment ->
// 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
// 提取最后的数字
val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId
} ?: false
isAssignee && !shouldExclude
}
val filteredComments = comments.filter { comment ->
// 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
// 提取最后的数字
val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId
} ?: false
isAssignee && !shouldExclude
}
.sortedByDescending { comment ->
parseGitHubTime(comment.created_at)
}
val total = filteredComments.size
val hasMore = total > 10
// 取前10条
val latest10 = filteredComments.take(10).map { comment ->
toAnnouncementDto(comment)
}
val result = AnnouncementListResponse(
list = latest10,
hasMore = hasMore,
total = total
)
// 只有在数据正常返回时才更新缓存(不是从缓存获取的)
if (!assigneesFromCache && !commentsFromCache) {
cachedList = CachedData(result, System.currentTimeMillis())
}
Result.success(result)
} catch (e: Exception) {
logger.error("获取公告列表异常: ${e.message}", e)
@@ -240,7 +240,7 @@ class AnnouncementService(
Result.failure(e)
}
}
/**
* 获取公告详情
* @param id 评论ID如果为 null 则返回最新一条
@@ -249,19 +249,18 @@ class AnnouncementService(
suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result<AnnouncementDto> {
return try {
// 获取 assignees
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
val (assigneeList, _) = getAssignees(forceRefresh)
// 获取评论列表
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
val (comments, _) = getIssueComments(forceRefresh)
// 筛选条件:
// 1. assignees 发布的评论
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
val filteredComments = comments
.filter { comment ->
val filteredComments = comments.filter { comment ->
// 检查是否为 assignee
val isAssignee = assigneeList.contains(comment.user.login)
// 检查是否应该排除(从 issue_url 中提取 issue ID
val shouldExclude = comment.issue_url?.let { issueUrl ->
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
@@ -269,37 +268,37 @@ class AnnouncementService(
val issueId = issueUrl.split("/").lastOrNull()
issueId == excludedIssueId
} ?: false
isAssignee && !shouldExclude
}
.sortedByDescending { comment ->
parseGitHubTime(comment.created_at)
}
val targetComment = if (id != null) {
filteredComments.find { it.id == id }
} else {
filteredComments.firstOrNull()
}
if (targetComment == null) {
return Result.failure(IllegalArgumentException("公告不存在"))
}
Result.success(toAnnouncementDto(targetComment))
} catch (e: Exception) {
logger.error("获取公告详情异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* GitHub 评论转换为 AnnouncementDto
*/
private fun toAnnouncementDto(comment: com.wrbug.polymarketbot.api.GitHubCommentResponse): AnnouncementDto {
private fun toAnnouncementDto(comment: GitHubCommentResponse): AnnouncementDto {
// 提取标题(第一行,移除 Markdown 格式)
val title = extractTitle(comment.body)
// 转换 reactions 数据
val reactions = comment.reactions?.let { r ->
com.wrbug.polymarketbot.dto.ReactionsDto(
@@ -314,7 +313,7 @@ class AnnouncementService(
total = r.total_count
)
}
return AnnouncementDto(
id = comment.id,
title = title,
@@ -326,7 +325,7 @@ class AnnouncementService(
reactions = reactions
)
}
/**
* 从评论内容中提取标题第一行移除 Markdown 格式
* 支持的 Markdown 格式
@@ -342,49 +341,49 @@ class AnnouncementService(
if (body.isBlank()) {
return ""
}
// 获取第一行
val firstLine = body.lines().firstOrNull()?.trim() ?: ""
if (firstLine.isBlank()) {
return ""
}
// 移除 Markdown 格式
var title = firstLine
// 移除标题标记(# ## ### 等)
title = title.replace(Regex("^#{1,6}\\s+"), "")
// 移除粗体标记(**text** 或 __text__
title = title.replace(Regex("\\*\\*([^*]+)\\*\\*"), "$1")
title = title.replace(Regex("__([^_]+)__"), "$1")
// 移除斜体标记(*text* 或 _text_
title = title.replace(Regex("(?<!\\*)\\*([^*]+)\\*(?!\\*)"), "$1")
title = title.replace(Regex("(?<!_)_([^_]+)_(?!_)"), "$1")
// 移除代码标记(`code`
title = title.replace(Regex("`([^`]+)`"), "$1")
// 移除链接标记([text](url)
title = title.replace(Regex("\\[([^\\]]+)\\]\\([^\\)]+\\)"), "$1")
// 移除图片标记(![alt](url)
title = title.replace(Regex("!\\[([^\\]]*)\\]\\([^\\)]+\\)"), "$1")
// 移除删除线标记(~~text~~
title = title.replace(Regex("~~([^~]+)~~"), "$1")
// 移除引用标记(> text
title = title.replace(Regex("^>\\s+"), "")
// 移除列表标记(- * + 1. 等)
title = title.replace(Regex("^[-*+]\\s+"), "")
title = title.replace(Regex("^\\d+\\.\\s+"), "")
return title.trim()
}
/**
* 解析 GitHub 时间格式ISO 8601为时间戳毫秒
* GitHub API 返回的时间格式2025-12-07T14:30:00Z
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.auth
import com.wrbug.polymarketbot.dto.CheckFirstUseResponse
import com.wrbug.polymarketbot.dto.LoginResponse
@@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import com.wrbug.polymarketbot.service.common.RateLimitService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -30,22 +31,44 @@ class AuthService(
private lateinit var resetPasswordKey: String
/**
* 登录
* 登录带IP限速保护
*/
fun login(username: String, password: String): Result<LoginResponse> {
fun login(username: String, password: String, ipAddress: String): Result<LoginResponse> {
return try {
// 检查登录频率限制
rateLimitService.checkLoginRateLimit(ipAddress).fold(
onSuccess = { },
onFailure = { e ->
return Result.failure(IllegalStateException(e.message ?: "登录频率限制"))
}
)
val user = userRepository.findByUsername(username)
?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
// 验证密码
if (!passwordEncoder.matches(password, user.password)) {
logger.warn("登录失败:密码错误,username=$username")
if (user == null) {
// 记录失败尝试
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
if (lockoutMsg != null) {
return Result.failure(IllegalStateException(lockoutMsg))
}
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 验证密码
if (!passwordEncoder.matches(password, user.password)) {
// 记录失败尝试
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
if (lockoutMsg != null) {
return Result.failure(IllegalStateException(lockoutMsg))
}
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 登录成功,清除失败记录
rateLimitService.clearLoginFailures(ipAddress)
// 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效)
val token = jwtUtils.generateToken(username, user.tokenVersion)
logger.info("用户登录成功:username=$username")
Result.success(LoginResponse(token = token))
} catch (e: Exception) {
@@ -0,0 +1,93 @@
package com.wrbug.polymarketbot.service.auth
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
/**
* WebSocket 票据服务
* 用于生成短期有效的一次性票据,避免在 WebSocket URL 中暴露 JWT
*/
@Service
class WebSocketTicketService {
companion object {
// 票据有效期(30秒)
private const val TICKET_VALIDITY_MS = 30_000L
// 票据长度(32字节 = 64个十六进制字符)
private const val TICKET_LENGTH = 32
}
private val secureRandom = SecureRandom()
// 存储票据:ticket -> TicketInfo
private val tickets = ConcurrentHashMap<String, TicketInfo>()
/**
* 票据信息
*/
data class TicketInfo(
val username: String,
val createdAt: Long,
val expiresAt: Long
)
/**
* 为用户生成 WebSocket 连接票据
* @param username 用户名
* @return 一次性票据
*/
fun generateTicket(username: String): String {
// 清理过期票据
cleanupExpiredTickets()
// 生成随机票据
val bytes = ByteArray(TICKET_LENGTH)
secureRandom.nextBytes(bytes)
val ticket = bytes.joinToString("") { "%02x".format(it) }
val now = System.currentTimeMillis()
tickets[ticket] = TicketInfo(
username = username,
createdAt = now,
expiresAt = now + TICKET_VALIDITY_MS
)
return ticket
}
/**
* 验证并消费票据(一次性使用)
* @param ticket 票据
* @return 用户名,如果票据无效则返回 null
*/
fun validateAndConsumeTicket(ticket: String): String? {
val ticketInfo = tickets.remove(ticket) ?: return null
// 检查是否过期
if (System.currentTimeMillis() > ticketInfo.expiresAt) {
return null
}
return ticketInfo.username
}
/**
* 清理过期票据
*/
private fun cleanupExpiredTickets() {
val now = System.currentTimeMillis()
tickets.entries.removeIf { it.value.expiresAt < now }
}
/**
* 定时清理过期票据(每分钟执行一次)
* 防止过期票据长时间占用内存
*/
@Scheduled(fixedRate = 60_000) // 60秒 = 60000毫秒
fun scheduledCleanup() {
cleanupExpiredTickets()
}
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.api.JsonRpcResponse
@@ -11,6 +12,8 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
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
@@ -25,10 +28,10 @@ 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 gson: Gson
) {
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
@@ -36,9 +39,16 @@ class BlockchainService(
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Polymarket 代理工厂合约地址(Polygon 主网)
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
private val safeProxyFactoryAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
// Polymarket Magic 代理工厂合约地址(Polygon 主网,用于邮箱/OAuth 登录用户)
// 合约地址: 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052
private val magicProxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
// Magic Proxy 的 init code hash(用于 CREATE2 计算)
private val magicProxyInitCodeHash = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"
// ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
@@ -63,79 +73,174 @@ class BlockchainService(
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.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)
}
/**
* 获取 Polymarket 代理钱包地址
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
* 根据指定的钱包类型返回对应的代理地址
*
* Polymarket 有两种代理钱包类型
* 1. Magic Proxy邮箱/OAuth 登录用户- 使用 CREATE2 计算地址
* 2. Safe ProxyMetaMask 钱包用户- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址EOA
* @param walletType 钱包类型"magic"默认 "safe"
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
val safeProxyAddress = safeProxyResult.getOrNull()!!
logger.debug("使用 Safe Proxy 地址: $safeProxyAddress")
Result.success(safeProxyAddress)
} else {
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
Result.success(magicProxyAddress)
}
}
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 计算 Magic Proxy 地址使用 CREATE2
* 用于邮箱/OAuth 登录的用户
*
* CREATE2 地址计算公式
* address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:]
* salt = keccak256(eoaAddress)
*
* @param walletAddress 用户的钱包地址EOA
* @return Magic 代理钱包地址
*/
fun calculateMagicProxyAddress(walletAddress: String): String {
// 计算 salt = keccak256(eoaAddress)
val eoaBytes = EthereumUtils.hexToBytes(walletAddress.lowercase())
val salt = EthereumUtils.keccak256(eoaBytes)
// 计算 CREATE2 地址
// data = 0xff ++ factory ++ salt ++ initCodeHash
val prefix = byteArrayOf(0xff.toByte())
val factoryBytes = EthereumUtils.hexToBytes(magicProxyFactoryAddress)
val initCodeHashBytes = EthereumUtils.hexToBytes(magicProxyInitCodeHash)
val data = prefix + factoryBytes + salt + initCodeHashBytes
val hash = EthereumUtils.keccak256(data)
// 取后 20 字节作为地址
return "0x" + hash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) }
}
/**
* 获取 Safe Proxy 地址
* 通过 RPC 调用 Safe 代理工厂合约获取用户的代理钱包地址
* 用于 MetaMask 钱包用户
*
* @param walletAddress 用户的钱包地址
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String): Result<String> {
private suspend fun getSafeProxyAddress(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)
// 编码地址参数
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
// 构建调用数据
val data = functionSelector + encodedAddress
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to proxyFactoryContractAddress,
"to" to safeProxyFactoryAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析代理地址
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
Result.success(proxyAddress)
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 检查地址是否是合约
* @param address 地址
* @return 如果地址有代码是合约返回 true
*/
private suspend fun isContract(address: String): Boolean {
return try {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_getCode",
params = listOf(address, "latest")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return false
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return false
}
val code = rpcResponse.result?.asString ?: "0x"
// 如果代码不是 "0x" 或 "0x0",则是合约
code != "0x" && code != "0x0"
} catch (e: Exception) {
logger.warn("检查合约地址失败: ${e.message}")
false
}
}
/**
* 查询账户 USDC 余额
@@ -146,12 +251,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("代理地址为空,无法查询余额")
@@ -172,7 +271,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)
@@ -207,7 +306,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)
@@ -263,13 +364,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) {
@@ -308,7 +403,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)")
@@ -338,7 +435,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())
@@ -448,7 +547,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()")
@@ -474,7 +573,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)
}
@@ -483,7 +584,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",
@@ -502,7 +603,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)
}
@@ -511,7 +614,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",
@@ -528,7 +631,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)
}
@@ -602,10 +707,79 @@ 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)
}
/**
* 从链上查询市场条件Condition的结算结果
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
*
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @return Result<Pair<payoutDenominator, payouts>>
* - payoutDenominator: 支付分母通常为 1
* - payouts: 每个 outcome 的支付金额数组0 1
* - 如果 payouts[outcomeIndex] == 1表示该 outcome 赢了
* - 如果 payouts[outcomeIndex] == 0表示该 outcome 输了
* - 如果 payouts 为空表示市场尚未结算
*/
suspend fun getCondition(conditionId: String): Result<Pair<BigInteger, List<BigInteger>>> {
return try {
// 验证 conditionId 格式
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
val rpcApi = polygonRpcApi
// 构建 getCondition(bytes32) 函数调用
// 函数签名: getCondition(bytes32)
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
val data = functionSelector + encodedConditionId
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析 ABI 编码的返回结果
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
Result.success(Pair(payoutDenominator, payouts))
} catch (e: Exception) {
logger.error("查询市场条件失败: conditionId=$conditionId, ${e.message}", e)
Result.failure(e)
}
}
/**
* 查询交易详情用于调试和分析
* @param txHash 交易哈希
@@ -613,11 +787,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(
@@ -635,7 +805,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(
@@ -652,7 +824,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")
@@ -0,0 +1,211 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
/**
* 市场价格服务
* 统一封装从不同数据源获取市场价格的逻辑
* 数据源包括:
* 1. 链上 RPC 查询(市场结算结果)
* 2. CLOB API(订单簿价格)
*/
@Service
class MarketPriceService(
private val blockchainService: BlockchainService,
private val retrofitFactory: RetrofitFactory,
private val accountRepository: AccountRepository,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 获取当前市场最新价
* 优先级:
* 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0)
* 2. CLOB API 查询订单簿价格(最准确,使用 bestBid)
*
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
*
* @param marketId 市场ID
* @param outcomeIndex 结果索引
* @return 市场价格(已截位到 4 位小数)
* @throws IllegalStateException 如果所有数据源都失败
*/
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
// 1. 优先从链上查询市场结算结果
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
if (chainPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 2. 从 CLOB API 查询订单簿价格(最准确)
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
if (orderbookPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果所有数据源都失败,抛出异常
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)"
logger.error(errorMsg)
throw IllegalStateException(errorMsg)
}
/**
* 从链上查询市场结算结果获取价格
* 如果市场已结算:
* - payout > 0(赢了)→ 返回 1.0
* - payout == 0(输了)→ 返回 0.0
* 如果市场未结算或查询失败,返回 null
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
onSuccess = { (_, payouts) ->
// 如果 payouts 不为空,说明市场已结算
if (payouts.isNotEmpty() && outcomeIndex < payouts.size) {
val payout = payouts[outcomeIndex]
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return BigDecimal.ONE
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return BigDecimal.ZERO
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
null
}
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
null
}
},
onFailure = { e ->
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
null
}
)
} catch (e: Exception) {
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* 从 CLOB API 查询订单簿价格
* 获取订单簿的 bestBid 和 bestAsk,计算 midpoint = (bestBid + bestAsk) / 2
* 订单簿数据最准确,反映当前市场真实价格
* 如果查询失败,返回 null
*/
private suspend fun getPriceFromClobOrderbook(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
// 获取 tokenId(用于查询特定 outcome 的订单簿)
val tokenIdResult = blockchainService.getTokenId(marketId, outcomeIndex)
if (!tokenIdResult.isSuccess) {
return null
}
val tokenId = tokenIdResult.getOrNull() ?: return null
// 尝试使用带鉴权的 CLOB API,如果没有则使用不带鉴权的 API
val clobApi = try {
getAuthenticatedClobApi() ?: retrofitFactory.createClobApiWithoutAuth()
} catch (e: Exception) {
logger.debug("获取带鉴权的 CLOB API 失败,使用不带鉴权的 API: ${e.message}")
retrofitFactory.createClobApiWithoutAuth()
}
val orderbookResponse = clobApi.getOrderbook(tokenId = tokenId, market = null)
if (!orderbookResponse.isSuccessful || orderbookResponse.body() == null) {
return null
}
val orderbook = orderbookResponse.body()!!
// 获取 bestBid(最高买入价):从 bids 中找到价格最大的
// bids 表示买入订单列表,价格越高表示愿意出的价格越高
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
// 获取 bestAsk(最低卖出价):从 asks 中找到价格最小的
// asks 表示卖出订单列表,价格越低表示愿意卖的价格越低
val bestAsk = orderbook.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
// 由于主要用于卖出场景,优先使用 bestBid(最高买入价,卖给愿意买入的人)
// 如果没有 bestBid,则使用 midpoint 或 bestAsk
if (bestBid != null) {
logger.debug("从订单簿获取价格(bestBid: marketId=$marketId, outcomeIndex=$outcomeIndex, bestBid=$bestBid, bestAsk=$bestAsk")
return bestBid
} else if (bestAsk != null && bestAsk > BigDecimal.ZERO) {
// 如果没有 bestBid,使用 bestAsk 作为备选
logger.debug("从订单簿获取价格(bestAsk: marketId=$marketId, outcomeIndex=$outcomeIndex, bestAsk=$bestAsk")
return bestAsk
}
null
} catch (e: Exception) {
logger.debug("CLOB API 查询订单簿失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* 获取带鉴权的 CLOB API 客户端
* 使用第一个有 API 凭证的账户
* 如果都没有,返回 null
*/
private fun getAuthenticatedClobApi(): PolymarketClobApi? {
return try {
// 使用第一个有 API 凭证的账户
val account = accountRepository.findAllByOrderByCreatedAtAsc()
.firstOrNull { it.apiKey != null && it.apiSecret != null && it.apiPassphrase != null }
if (account == null || account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return null
}
// 解密 API 凭证
val apiKey = account.apiKey
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret)
} catch (e: Exception) {
logger.debug("解密 API Secret 失败: ${e.message}")
return null
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase)
} catch (e: Exception) {
logger.debug("解密 API Passphrase 失败: ${e.message}")
return null
}
// 创建带鉴权的 CLOB API 客户端
retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
} catch (e: Exception) {
logger.debug("获取带鉴权的 CLOB API 失败: ${e.message}")
null
}
}
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.ApiKeyResponse
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
@@ -19,7 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
@Service
class PolymarketApiKeyService(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String
private val clobBaseUrl: String,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java)
@@ -52,34 +54,36 @@ class PolymarketApiKeyService(
try {
// 先尝试获取现有的 API Keyderive
val deriveResult = deriveApiKey(privateKey, walletAddress, chainId)
val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}"
if (deriveResult.isSuccess) {
val creds = deriveResult.getOrNull()
if (creds != null && isApiCreds(creds)) {
logger.info("成功获取现有 API Key: ${walletAddress}")
logger.debug("成功获取现有 API Key: $maskedAddress")
return@runBlocking Result.success(creds)
}
}
// 如果获取失败或返回无效,尝试创建新的
logger.info("获取现有 API Key 失败,尝试创建新的: ${walletAddress}")
logger.debug("获取现有 API Key 失败,尝试创建新的: $maskedAddress")
val createResult = createApiKey(privateKey, walletAddress, chainId)
if (createResult.isSuccess) {
val creds = createResult.getOrNull()
if (creds != null && isApiCreds(creds)) {
logger.info("成功创建新 API Key: ${walletAddress}")
logger.debug("成功创建新 API Key: $maskedAddress")
return@runBlocking Result.success(creds)
}
}
// 两个都失败
val error = createResult.exceptionOrNull() ?: deriveResult.exceptionOrNull()
val errorMsg = error?.message ?: "未知错误"
logger.error("获取和创建 API Key 都失败: ${walletAddress}", error)
logger.error("获取和创建 API Key 都失败: $maskedAddress", error)
Result.failure(
IllegalStateException("无法获取或创建 API Key: $errorMsg")
)
} catch (e: Exception) {
logger.error("创建或获取 API Key 异常: ${walletAddress}", e)
val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}"
logger.error("创建或获取 API Key 异常: $maskedAddress", e)
Result.failure(e)
}
}
@@ -222,7 +226,7 @@ class PolymarketApiKeyService(
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -236,7 +240,7 @@ class PolymarketApiKeyService(
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.util.RetrofitFactory
@@ -300,13 +300,23 @@ class PolymarketClobService(
)
val response = authenticatedClobApi.getOrder(orderId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.success(body)
} else {
// 响应体为空,可能是订单不存在或已过期
logger.warn("获取订单详情失败: 响应体为空, orderId=$orderId, code=${response.code()}")
Result.failure(Exception("订单不存在或已过期: orderId=$orderId"))
}
} else {
Result.failure(Exception("获取订单详情失败: ${response.code()} ${response.message()}"))
// HTTP 状态码不是 2xx
val errorBody = response.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.warn("获取订单详情失败: HTTP ${response.code()}, orderId=$orderId, errorBody=$errorBody")
Result.failure(Exception("获取订单详情失败: HTTP ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取订单详情异常: ${e.message}", e)
logger.error("获取订单详情异常: orderId=$orderId, ${e.message}", e)
Result.failure(e)
}
}
@@ -0,0 +1,135 @@
package com.wrbug.polymarketbot.service.common
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* 频率限制服务(使用内存缓存)
*/
@Service
class RateLimitService {
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
// 重置密码限速配置
@Value("\${rate-limit.reset-password.max-attempts:3}")
private var resetPasswordMaxAttempts: Int = 3
@Value("\${rate-limit.reset-password.window-seconds:60}")
private var resetPasswordWindowSeconds: Long = 60
// 登录限速配置
@Value("\${rate-limit.login.max-attempts:5}")
private var loginMaxAttempts: Int = 5
@Value("\${rate-limit.login.window-seconds:300}")
private var loginWindowSeconds: Long = 300 // 5分钟
@Value("\${rate-limit.login.lockout-seconds:900}")
private var loginLockoutSeconds: Long = 900 // 15分钟
// 全局尝试记录列表(时间戳),所有请求共享
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
// 登录失败尝试记录(IP -> 时间戳列表)
private val loginFailedAttempts = ConcurrentHashMap<String, MutableList<Long>>()
// 登录锁定记录(IP -> 锁定结束时间)
private val loginLockouts = ConcurrentHashMap<String, Long>()
/**
* 检查重置密码频率限制(全局限制,不按IP)
* @return Result,如果超过限制则返回失败
*/
fun checkResetPasswordRateLimit(): Result<Unit> {
val now = System.currentTimeMillis()
val windowStart = now - (resetPasswordWindowSeconds * 1000)
// 获取当前尝试记录列表
val attempts = resetPasswordAttempts.get()
// 清理过期记录(超过时间窗口的记录)
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
// 检查是否超过限制
if (validAttempts.size >= resetPasswordMaxAttempts) {
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts")
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试"))
}
// 记录本次尝试
validAttempts.add(now)
resetPasswordAttempts.set(validAttempts)
return Result.success(Unit)
}
/**
* 检查登录频率限制(按IP限制)
* @param ipAddress 客户端IP地址
* @return Result,如果被锁定或超过限制则返回失败
*/
fun checkLoginRateLimit(ipAddress: String): Result<Unit> {
val now = System.currentTimeMillis()
// 检查是否被锁定
val lockoutEndTime = loginLockouts[ipAddress]
if (lockoutEndTime != null) {
if (now < lockoutEndTime) {
val remainingSeconds = (lockoutEndTime - now) / 1000
logger.warn("登录锁定中: ip=$ipAddress, remainingSeconds=$remainingSeconds")
return Result.failure(IllegalStateException("账户已被锁定,请${remainingSeconds}秒后再试"))
} else {
// 锁定已过期,清除锁定记录
loginLockouts.remove(ipAddress)
loginFailedAttempts.remove(ipAddress)
}
}
return Result.success(Unit)
}
/**
* 记录登录失败尝试
* @param ipAddress 客户端IP地址
* @return 如果触发锁定返回锁定信息,否则返回 null
*/
fun recordLoginFailure(ipAddress: String): String? {
val now = System.currentTimeMillis()
val windowStart = now - (loginWindowSeconds * 1000)
// 获取或创建该IP的尝试记录
val attempts = loginFailedAttempts.computeIfAbsent(ipAddress) { mutableListOf() }
// 清理过期记录并添加新记录
synchronized(attempts) {
attempts.removeIf { it < windowStart }
attempts.add(now)
// 检查是否需要锁定
if (attempts.size >= loginMaxAttempts) {
val lockoutEndTime = now + (loginLockoutSeconds * 1000)
loginLockouts[ipAddress] = lockoutEndTime
logger.warn("登录锁定触发: ip=$ipAddress, attempts=${attempts.size}, lockoutSeconds=$loginLockoutSeconds")
return "登录失败次数过多,账户已被锁定${loginLockoutSeconds / 60}分钟"
}
}
val remainingAttempts = loginMaxAttempts - attempts.size
logger.warn("登录失败: ip=$ipAddress, attempts=${attempts.size}/$loginMaxAttempts, remainingAttempts=$remainingAttempts")
return null
}
/**
* 登录成功时清除失败记录
* @param ipAddress 客户端IP地址
*/
fun clearLoginFailures(ipAddress: String) {
loginFailedAttempts.remove(ipAddress)
loginLockouts.remove(ipAddress)
}
}
@@ -1,9 +1,11 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.accounts.PositionPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -0,0 +1,275 @@
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.api.OrderbookResponse
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lt
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
/**
* 跟单过滤条件检查服务
*/
@Service
class CopyTradingFilterService(
private val clobService: PolymarketClobService,
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
/**
* 检查过滤条件
* @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 交易价格,用于价格区间检查
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
): FilterResult {
// 1. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.isPassed) {
return FilterResult.priceRangeFailed(priceRangeCheck.reason)
}
}
// 2. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
if (!needOrderbook) {
// 不需要订单簿,直接通过
return FilterResult.passed()
}
// 3. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
return FilterResult.orderbookError("获取订单簿失败: ${error?.message ?: "未知错误"}")
}
val orderbook = orderbookResult.getOrNull()
?: return FilterResult.orderbookEmpty()
// 4. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
return FilterResult.spreadFailed(spreadCheck.reason, orderbook)
}
}
// 5. 订单深度过滤(如果配置了,检查所有方向)
if (copyTrading.minOrderDepth != null) {
val depthCheck = checkOrderDepth(copyTrading, orderbook)
if (!depthCheck.isPassed) {
return FilterResult.orderDepthFailed(depthCheck.reason, orderbook)
}
}
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
if (!positionCheck.isPassed) {
return positionCheck
}
}
return FilterResult.passed(orderbook)
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
* @param tradePrice Leader 交易价格
* @return 过滤结果
*/
private fun checkPriceRange(
copyTrading: CopyTrading,
tradePrice: BigDecimal
): FilterResult {
// 如果未配置价格区间,直接通过
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
return FilterResult.passed()
}
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
}
return FilterResult.passed()
}
/**
* 检查买一卖一价差
* bestBid: 买盘中的最高价格(最大值)
* bestAsk: 卖盘中的最低价格(最小值)
*/
private fun checkSpread(
copyTrading: CopyTrading,
orderbook: OrderbookResponse
): FilterResult {
// 如果未启用价差过滤,直接通过
if (copyTrading.maxSpread == null) {
return FilterResult.passed()
}
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
// 获取卖盘中的最低价格(bestAsk = asks 中的最小值)
val bestAsk = orderbook.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestBid == null || bestAsk == null) {
return FilterResult.spreadFailed("订单簿缺少买一或卖一价格", orderbook)
}
// 计算价差(绝对价格)
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
}
return FilterResult.passed()
}
/**
* 检查订单深度(检查所有方向:买盘和卖盘的总深度)
*/
private fun checkOrderDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse
): FilterResult {
// 如果未启用订单深度过滤,直接通过
if (copyTrading.minOrderDepth == null) {
return FilterResult.passed()
}
// 计算买盘(bids)总深度
var bidsDepth = BigDecimal.ZERO
for (order in orderbook.bids) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
bidsDepth = bidsDepth.add(orderAmount)
}
// 计算卖盘(asks)总深度
var asksDepth = BigDecimal.ZERO
for (order in orderbook.asks) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
asksDepth = asksDepth.add(orderAmount)
}
// 计算总深度(买盘 + 卖盘)
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
}
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}")
}
}
}
@@ -1,11 +1,14 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -82,9 +85,10 @@ class CopyTradingService(
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
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 {
// 手动输入(所有字段必须提供)
@@ -109,9 +113,10 @@ class CopyTradingService(
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
)
}
@@ -136,22 +141,24 @@ class CopyTradingService(
supportSell = config.supportSell,
minOrderDepth = config.minOrderDepth,
maxSpread = config.maxSpread,
minOrderbookDepth = config.minOrderbookDepth,
minPrice = config.minPrice,
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false
)
val saved = copyTradingRepository.save(copyTrading)
// 如果跟单已启用,重新启动监听(确保状态完全同步
// 如果跟单已启用,更新 Leader 监听和账户监听(增量更新,不重启所有监听
if (saved.enabled) {
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
monitorService.updateLeaderMonitoring(saved.leaderId)
monitorService.updateAccountMonitoring(saved.accountId)
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
logger.error("更新监听失败", e)
}
}
}
@@ -202,9 +209,10 @@ class CopyTradingService(
supportSell = request.supportSell ?: copyTrading.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
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()
@@ -212,12 +220,13 @@ class CopyTradingService(
val saved = copyTradingRepository.save(updated)
// 重新启动监听(确保状态完全同步
// 更新 Leader 监听和账户监听(增量更新,根据 enabled 状态决定添加或移除
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
monitorService.updateLeaderMonitoring(saved.leaderId)
monitorService.updateAccountMonitoring(saved.accountId)
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
logger.error("更新监听失败", e)
}
}
@@ -315,14 +324,17 @@ class CopyTradingService(
val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
val leaderId = copyTrading.leaderId
val accountId = copyTrading.accountId
copyTradingRepository.delete(copyTrading)
// 重新启动监听(确保状态完全同步
// 更新 Leader 监听和账户监听(检查是否还有其他启用的跟单配置
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
monitorService.removeLeaderMonitoring(leaderId)
monitorService.updateAccountMonitoring(accountId)
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
logger.error("更新监听失败", e)
}
}
@@ -339,7 +351,7 @@ class CopyTradingService(
fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> {
return try {
// 验证账户是否存在
val account = accountRepository.findById(accountId).orElse(null)
accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
val copyTradings = copyTradingRepository.findByAccountId(accountId)
@@ -380,8 +392,8 @@ class CopyTradingService(
*/
private fun toDto(
copyTrading: CopyTrading,
account: com.wrbug.polymarketbot.entity.Account,
leader: com.wrbug.polymarketbot.entity.Leader
account: Account,
leader: Leader
): CopyTradingDto {
return CopyTradingDto(
id = copyTrading.id!!,
@@ -408,9 +420,10 @@ class CopyTradingService(
supportSell = copyTrading.supportSell,
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
maxSpread = copyTrading.maxSpread?.toPlainString(),
minOrderbookDepth = copyTrading.minOrderbookDepth?.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,
@@ -438,8 +451,9 @@ class CopyTradingService(
val supportSell: Boolean,
val minOrderDepth: BigDecimal?,
val maxSpread: BigDecimal?,
val minOrderbookDepth: BigDecimal?,
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?
)
}
@@ -0,0 +1,94 @@
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.api.OrderbookResponse
/**
* 过滤结果状态枚举
*/
enum class FilterStatus {
/** 通过 */
PASSED,
/** 失败:价格区间 */
FAILED_PRICE_RANGE,
/** 失败:订单簿获取失败 */
FAILED_ORDERBOOK_ERROR,
/** 失败:订单簿为空 */
FAILED_ORDERBOOK_EMPTY,
/** 失败:价差过大 */
FAILED_SPREAD,
/** 失败:订单深度不足 */
FAILED_ORDER_DEPTH,
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT
}
/**
* 过滤结果
*/
data class FilterResult(
/** 过滤状态 */
val status: FilterStatus,
/** 失败原因(仅在失败时有效) */
val reason: String = "",
/** 订单簿(仅在需要时返回) */
val orderbook: OrderbookResponse? = null
) {
/** 是否通过 */
val isPassed: Boolean
get() = status == FilterStatus.PASSED
companion object {
/** 通过 */
fun passed(orderbook: OrderbookResponse? = null) = FilterResult(
status = FilterStatus.PASSED,
orderbook = orderbook
)
/** 价格区间失败 */
fun priceRangeFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_PRICE_RANGE,
reason = reason
)
/** 订单簿获取失败 */
fun orderbookError(reason: String) = FilterResult(
status = FilterStatus.FAILED_ORDERBOOK_ERROR,
reason = reason
)
/** 订单簿为空 */
fun orderbookEmpty() = FilterResult(
status = FilterStatus.FAILED_ORDERBOOK_EMPTY,
reason = "订单簿为空"
)
/** 价差过大 */
fun spreadFailed(reason: String, orderbook: OrderbookResponse) = FilterResult(
status = FilterStatus.FAILED_SPREAD,
reason = reason,
orderbook = orderbook
)
/** 订单深度不足 */
fun orderDepthFailed(reason: String, orderbook: OrderbookResponse) = FilterResult(
status = FilterStatus.FAILED_ORDER_DEPTH,
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
)
}
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.FilteredOrderDto
import com.wrbug.polymarketbot.dto.FilteredOrderListRequest
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
@@ -0,0 +1,341 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
* 跟单账户链上 WebSocket 监听服务
* 通过统一服务订阅跟单账户的卖出和赎回事件
* 用于更新订单状态,不再依赖轮询
*/
@Service
class AccountOnChainMonitorService(
private val unifiedOnChainWsService: UnifiedOnChainWsService,
private val retrofitFactory: RetrofitFactory,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository
) {
private val logger = LoggerFactory.getLogger(AccountOnChainMonitorService::class.java)
// 存储需要监听的账户:accountId -> Account
private val monitoredAccounts = ConcurrentHashMap<Long, Account>()
/**
* 启动链上 WebSocket 监听
* 通过统一服务订阅所有跟单账户
*/
fun start(accounts: List<Account>) {
// 如果没有账户,取消所有订阅
if (accounts.isEmpty()) {
logger.info("没有需要监听的跟单账户,取消所有订阅")
stop()
return
}
// 更新账户列表
monitoredAccounts.clear()
accounts.forEach { account ->
addAccount(account)
}
}
/**
* 添加账户监听
* 通过统一服务订阅该账户的地址
*/
fun addAccount(account: Account) {
if (account.id == null) {
logger.warn("账户 ID 为空,跳过: ${account.proxyAddress}")
return
}
val accountId = account.id!!
// 如果已经在监听列表中,不重复添加
if (monitoredAccounts.containsKey(accountId)) {
return
}
monitoredAccounts[accountId] = account
// 通过统一服务订阅
val subscriptionId = "ACCOUNT_$accountId"
unifiedOnChainWsService.subscribe(
subscriptionId = subscriptionId,
address = account.proxyAddress,
entityType = "ACCOUNT",
entityId = accountId,
callback = { txHash, httpClient, rpcApi ->
handleAccountTransaction(accountId, txHash, httpClient, rpcApi)
}
)
logger.info("已添加跟单账户进行链上监听: accountId=${accountId}, address=${account.proxyAddress}")
}
/**
* 处理账户的交易
*/
private suspend fun handleAccountTransaction(accountId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val account = monitoredAccounts[accountId] ?: return
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
txHash = txHash,
timestamp = blockTimestamp,
walletAddress = account.proxyAddress,
erc20Transfers = erc20Transfers,
erc1155Transfers = erc1155Transfers,
retrofitFactory = retrofitFactory
)
if (trade != null && trade.side == "SELL") {
// 检测到卖出或赎回事件,更新订单状态
handleAccountSellOrRedeem(account, trade)
}
} catch (e: Exception) {
logger.error("处理账户交易失败: accountId=$accountId, txHash=$txHash, ${e.message}", e)
}
}
/**
* 处理账户的卖出或赎回事件
* 更新对应的订单状态
*/
private suspend fun handleAccountSellOrRedeem(account: Account, trade: TradeResponse) {
try {
// 获取该账户的所有启用的跟单配置
val copyTradings = copyTradingRepository.findByAccountId(account.id!!)
.filter { it.enabled }
if (copyTradings.isEmpty()) {
return
}
// 使用 trade 中已有的市场信息
val marketId = trade.market // conditionId
val outcomeIndex = trade.outcomeIndex ?: 0
// 计算卖出价格
val sellPrice = trade.price.toSafeBigDecimal()
// 为每个跟单配置更新订单状态
for (copyTrading in copyTradings) {
// 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0
val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!)
.filter {
it.remainingQuantity > BigDecimal.ZERO &&
it.marketId == marketId &&
it.outcomeIndex == outcomeIndex
}
.sortedBy { it.createdAt } // 按创建时间排序(FIFO
if (unmatchedOrders.isEmpty()) {
continue
}
// 卖出数量就是交易的 size
val soldQuantity = trade.size.toSafeBigDecimal()
// 更新订单状态为已卖出
updateOrdersAsSoldByFIFO(
unmatchedOrders,
soldQuantity,
sellPrice,
copyTrading.id!!,
marketId,
outcomeIndex
)
logger.info("跟单账户卖出/赎回事件处理完成: accountId=${account.id}, copyTradingId=${copyTrading.id}, txHash=${trade.id}, soldQuantity=$soldQuantity, sellPrice=$sellPrice")
}
} catch (e: Exception) {
logger.error("处理账户卖出/赎回事件失败: accountId=${account.id}, txHash=${trade.id}, error=${e.message}", e)
}
}
/**
* 按 FIFO 顺序更新订单为已卖出
*/
private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>,
soldQuantity: BigDecimal,
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
outcomeIndex: Int
) {
var remainingSoldQuantity = soldQuantity
val matchDetails = mutableListOf<SellMatchDetail>()
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
for (order in orders) {
if (remainingSoldQuantity <= BigDecimal.ZERO) {
break
}
val currentOrderRemaining = order.remainingQuantity.toSafeBigDecimal()
val matchedQty = minOf(currentOrderRemaining, remainingSoldQuantity)
if (matchedQty <= BigDecimal.ZERO) {
continue
}
// 计算盈亏
val buyPrice = order.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchedQty)
// 创建匹配明细
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = matchedQty,
buyPrice = buyPrice,
sellPrice = sellPrice,
realizedPnl = realizedPnl
)
matchDetails.add(detail)
totalMatchedQuantity = totalMatchedQuantity.add(matchedQty)
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
// 更新订单状态
order.matchedQuantity = order.matchedQuantity.add(matchedQty)
order.remainingQuantity = currentOrderRemaining.subtract(matchedQty)
order.status = if (order.remainingQuantity <= BigDecimal.ZERO) "fully_matched" else "partially_matched"
order.updatedAt = System.currentTimeMillis()
copyOrderTrackingRepository.save(order)
remainingSoldQuantity = remainingSoldQuantity.subtract(matchedQty)
}
// 如果有匹配的订单,创建卖出记录
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
val timestamp = System.currentTimeMillis()
val sellOrderId = "AUTO_WS_${timestamp}_${copyTradingId}" // 区分 WS 自动卖出
val leaderSellTradeId = "AUTO_WS_${timestamp}"
val matchRecord = SellMatchRecord(
copyTradingId = copyTradingId,
sellOrderId = sellOrderId,
leaderSellTradeId = leaderSellTradeId,
marketId = marketId,
side = outcomeIndex.toString(),
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl,
priceUpdated = true // WS 实时获取,直接标记为已更新
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
// 保存匹配明细
for (detail in matchDetails) {
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("创建跟单账户链上自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
}
}
/**
* 移除账户监听
* 取消该账户的订阅
*/
fun removeAccount(accountId: Long) {
monitoredAccounts.remove(accountId)
// 通过统一服务取消订阅
val subscriptionId = "ACCOUNT_$accountId"
unifiedOnChainWsService.unsubscribe(subscriptionId)
logger.info("已移除跟单账户的链上监听: accountId=$accountId")
}
/**
* 更新账户监听状态
*/
fun updateAccountMonitoring(accountId: Long) {
val account = accountRepository.findById(accountId).orElse(null)
if (account != null && account.isEnabled) {
addAccount(account)
} else {
removeAccount(accountId)
}
}
/**
* 停止监听
*/
fun stop() {
// 取消所有账户的订阅
val accountIds = monitoredAccounts.keys.toList()
for (accountId in accountIds) {
removeAccount(accountId)
}
monitoredAccounts.clear()
}
@PreDestroy
fun destroy() {
stop()
}
}
@@ -0,0 +1,190 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
/**
* 跟单监听服务(主服务)
* 管理所有Leader的交易监听
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket
*/
@Service
class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val pollingService: CopyTradingPollingService,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService
) {
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
/**
* 系统启动时初始化监听
*/
@PostConstruct
fun init() {
scope.launch {
try {
startMonitoring()
} catch (e: Exception) {
logger.error("启动跟单监听失败", e)
}
}
}
/**
* 系统关闭时清理资源
*/
@PreDestroy
fun destroy() {
scope.cancel()
// 停止轮询和链上 WS 监听
pollingService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
/**
* 启动监听
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
*/
suspend fun startMonitoring() {
// 1. 获取所有启用的跟单关系
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
if (enabledCopyTradings.isEmpty()) {
return
}
// 2. 获取所有需要监听的Leader(去重)
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
val leaders = leaderIds.mapNotNull { leaderId ->
leaderRepository.findById(leaderId).orElse(null)
}
// 3. 获取所有需要监听的跟单账户(去重)
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
val accounts = accountIds.mapNotNull { accountId ->
accountRepository.findById(accountId).orElse(null)
}
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
onChainWsService.start(leaders)
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
pollingService.start(leaders)
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
accountOnChainMonitorService.start(accounts)
}
/**
* 添加Leader监听(当创建新的跟单关系时调用)
* 如果 Leader 已经在监听列表中,不重复添加
*/
suspend fun addLeaderMonitoring(leaderId: Long) {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isEmpty()) {
return
}
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
}
/**
* 移除Leader监听(当删除跟单关系或禁用时调用)
* 检查该 Leader 是否还有其他启用的跟单配置
*/
suspend fun removeLeaderMonitoring(leaderId: Long) {
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
// 如果还有启用的跟单配置,不移除监听
if (copyTradings.isNotEmpty()) {
return
}
// 没有启用的跟单配置了,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
/**
* 更新Leader监听(当跟单配置状态改变时调用)
* 根据当前状态决定添加或移除监听
*/
suspend fun updateLeaderMonitoring(leaderId: Long) {
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保在监听列表中
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
// 更新账户监听(添加该配置关联的账户)
val accountIds = copyTradings.map { it.accountId }.distinct()
accountIds.forEach { accountId ->
val account = accountRepository.findById(accountId).orElse(null)
if (account != null) {
accountOnChainMonitorService.addAccount(account)
}
}
} else {
// 没有启用的跟单配置,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
}
/**
* 更新账户监听(当跟单配置状态改变时调用)
* 根据当前状态决定添加或移除账户监听
*/
suspend fun updateAccountMonitoring(accountId: Long) {
val copyTradings = copyTradingRepository.findByAccountId(accountId)
.filter { it.enabled }
val account = accountRepository.findById(accountId).orElse(null)
?: return
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保账户在监听列表中
accountOnChainMonitorService.addAccount(account)
} else {
// 没有启用的跟单配置,移除账户监听
accountOnChainMonitorService.removeAccount(accountId)
}
}
/**
* 重新启动监听(当跟单关系状态改变时调用)
* 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新
*/
suspend fun restartMonitoring() {
// 停止所有监听
onChainWsService.stop()
pollingService.stop()
delay(1000) // 等待1秒
startMonitoring()
}
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.api.UserActivityResponse
@@ -9,6 +9,7 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap
@@ -72,7 +73,7 @@ class CopyTradingPollingService(
return
}
val leaderId = leader.id!!
val leaderId = leader.id
monitoredLeaders[leaderId] = leader
// 初始化缓存的交易ID集合
cachedTradeIds[leaderId] = mutableSetOf()
@@ -178,7 +179,7 @@ class CopyTradingPollingService(
return
}
val leaderId = leader.id!!
val leaderId = leader.id
val leaderAddress = leader.leaderAddress
try {
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonObject
@@ -11,6 +11,7 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -21,15 +22,14 @@ import java.util.concurrent.ConcurrentHashMap
@Service
class CopyTradingWebSocketService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val templateRepository: CopyTradingTemplateRepository
private val templateRepository: CopyTradingTemplateRepository,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val gson = Gson()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
@@ -65,7 +65,7 @@ class CopyTradingWebSocketService(
return
}
val leaderId = leader.id!!
val leaderId = leader.id
val leaderAddress = leader.leaderAddress.lowercase()
leaderAddresses[leaderId] = leaderAddress
@@ -0,0 +1,177 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import com.wrbug.polymarketbot.util.RetrofitFactory
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 链上 WebSocket 监听服务
* 通过统一服务订阅 Leader 的链上交易
*/
@Service
class OnChainWsService(
private val unifiedOnChainWsService: UnifiedOnChainWsService,
private val retrofitFactory: RetrofitFactory,
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
/**
* 启动链上 WebSocket 监听
* 通过统一服务订阅所有 Leader
*/
fun start(leaders: List<Leader>) {
// 如果没有 Leader,取消所有订阅
if (leaders.isEmpty()) {
logger.info("没有需要监听的 Leader,取消所有订阅")
stop()
return
}
// 更新 Leader 列表
monitoredLeaders.clear()
leaders.forEach { leader ->
addLeader(leader)
}
}
/**
* 添加Leader监听
* 通过统一服务订阅该 Leader 的地址
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
return
}
val leaderId = leader.id!!
// 如果已经在监听列表中,不重复添加
if (monitoredLeaders.containsKey(leaderId)) {
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
return
}
monitoredLeaders[leaderId] = leader
// 通过统一服务订阅
val subscriptionId = "LEADER_$leaderId"
unifiedOnChainWsService.subscribe(
subscriptionId = subscriptionId,
address = leader.leaderAddress,
entityType = "LEADER",
entityId = leaderId,
callback = { txHash, httpClient, rpcApi ->
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
}
)
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
}
/**
* 处理 Leader 的交易
*/
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val leader = monitoredLeaders[leaderId] ?: return
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
txHash = txHash,
timestamp = blockTimestamp,
walletAddress = leader.leaderAddress,
erc20Transfers = erc20Transfers,
erc1155Transfers = erc1155Transfers,
retrofitFactory = retrofitFactory
)
if (trade != null) {
// 调用 processTrade 处理交易
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "onchain-ws"
)
}
} catch (e: Exception) {
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
}
}
/**
* 移除Leader监听
* 取消该 Leader 的订阅
*/
fun removeLeader(leaderId: Long) {
monitoredLeaders.remove(leaderId)
// 通过统一服务取消订阅
val subscriptionId = "LEADER_$leaderId"
unifiedOnChainWsService.unsubscribe(subscriptionId)
logger.info("移除 Leader 监听: leaderId=$leaderId")
}
/**
* 停止监听
*/
fun stop() {
// 取消所有 Leader 的订阅
val leaderIds = monitoredLeaders.keys.toList()
for (leaderId in leaderIds) {
removeLeader(leaderId)
}
monitoredLeaders.clear()
}
@PreDestroy
fun destroy() {
stop()
}
}
@@ -0,0 +1,377 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.reflect.TypeToken
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.service.system.RpcNodeService
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import java.math.BigInteger
/**
* 链上 WebSocket 工具类
* 提取公共的交易解析、工具函数等逻辑
*/
object OnChainWsUtils {
private val logger = LoggerFactory.getLogger(OnChainWsUtils::class.java)
// 创建 Gson 实例(与 GsonConfig 中的配置一致,使用 lenient 模式)
private val gson: Gson = GsonBuilder()
.setLenient()
.create()
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]"
* @return 字符串列表,如果解析失败返回空列表
*/
private fun parseStringArray(jsonString: String?): List<String> {
if (jsonString.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
// 合约地址
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
/**
* ERC20 Transfer 数据类
*/
data class Erc20Transfer(
val from: String,
val to: String,
val value: BigInteger
)
/**
* ERC1155 Transfer 数据类
*/
data class Erc1155Transfer(
val from: String,
val to: String,
val tokenId: BigInteger,
val value: BigInteger
)
/**
* 市场信息数据类
*/
data class MarketInfo(
val conditionId: String,
val outcomeIndex: Int?, // 可空,因为可能找不到对应的 tokenId
val outcome: String?
)
/**
* 解析 receipt 中的 Transfer 日志
*/
fun parseReceiptTransfers(logs: JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
val erc20 = mutableListOf<Erc20Transfer>()
val erc1155 = mutableListOf<Erc1155Transfer>()
for (logElement in logs) {
val log = logElement.asJsonObject
val address = log.get("address")?.asString?.lowercase() ?: continue
val topicsArray = log.getAsJsonArray("topics") ?: continue
val topics = topicsArray.mapNotNull { it.asString }
if (topics.isEmpty()) continue
val t0 = topics[0].lowercase()
val data = log.get("data")?.asString ?: "0x"
// USDC ERC20 Transfer
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
val from = topicToAddress(topics[1])
val to = topicToAddress(topics[2])
val value = hexToBigInt(data)
erc20.add(Erc20Transfer(from, to, value))
continue
}
// ERC1155 TransferSingle
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
val from = topicToAddress(topics[2])
val to = topicToAddress(topics[3])
val bytes = bytesFromHex(data)
if (bytes.size >= 64) {
val tokenId = sliceBigInt32(bytes, 0)
val value = sliceBigInt32(bytes, 32)
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
}
continue
}
// ERC1155 TransferBatch
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
val from = topicToAddress(topics[2])
val to = topicToAddress(topics[3])
val bytes = bytesFromHex(data)
if (bytes.size < 64) continue
val offIds = sliceBigInt32(bytes, 0).toInt()
val offVals = sliceBigInt32(bytes, 32).toInt()
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
val nIds = sliceBigInt32(bytes, offIds).toInt()
val nVals = sliceBigInt32(bytes, offVals).toInt()
if (nIds != nVals) continue
val idsStart = offIds + 32
val valsStart = offVals + 32
for (i in 0 until nIds) {
val ib = idsStart + i * 32
val vb = valsStart + i * 32
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
val tokenId = sliceBigInt32(bytes, ib)
val value = sliceBigInt32(bytes, vb)
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
}
}
}
return Pair(erc20, erc1155)
}
/**
* 从 Transfer 日志解析交易信息
*/
suspend fun parseTradeFromTransfers(
txHash: String,
timestamp: Long?,
walletAddress: String,
erc20Transfers: List<Erc20Transfer>,
erc1155Transfers: List<Erc1155Transfer>,
retrofitFactory: RetrofitFactory
): TradeResponse? {
val wallet = walletAddress.lowercase()
// 计算 USDC 流入和流出
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
val inById = mutableMapOf<BigInteger, BigInteger>()
val outById = mutableMapOf<BigInteger, BigInteger>()
for (t in erc1155Transfers) {
if (t.to.lowercase() == wallet) {
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
}
if (t.from.lowercase() == wallet) {
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
}
}
// 找到最大的流入和流出 tokenId
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
val (bestInId, bestInVal) = best(inById)
val (bestOutId, bestOutVal) = best(outById)
// 判断交易方向
var side: String? = null
var asset: BigInteger? = null
var sizeRaw = BigInteger.ZERO
var usdcRaw = BigInteger.ZERO
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
// BUY: 收到 token,支付 USDC
side = "BUY"
asset = bestInId
sizeRaw = bestInVal
usdcRaw = usdcOut
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
// SELL: 卖出 token,收到 USDC
side = "SELL"
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = usdcIn
} else {
// 无法判断交易方向
return null
}
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
val price = if (size.signum() > 0) {
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
} else {
return null
}
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
// 创建 TradeResponse
return TradeResponse(
id = txHash,
market = marketInfo?.conditionId ?: "",
side = side,
price = price.toPlainString(),
size = size.toPlainString(),
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
user = walletAddress,
outcomeIndex = marketInfo?.outcomeIndex,
outcome = marketInfo?.outcome
)
}
/**
* 通过 Gamma API 查询市场信息(通过 tokenId)
* 使用 Retrofit 接口,支持 clob_token_ids 参数
*/
suspend fun fetchMarketByTokenId(tokenId: String, retrofitFactory: RetrofitFactory): MarketInfo? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val marketsResponse = gammaApi.listMarkets(
conditionIds = null,
clobTokenIds = listOf(tokenId),
includeTag = null
)
if (!marketsResponse.isSuccessful || marketsResponse.body() == null) {
return null
}
val markets = marketsResponse.body()!!
val market = markets.firstOrNull()
if (market == null) {
return null
}
// 解析 clobTokenIds(可能是 JSON 字符串或数组)
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
val clobTokenIds = when {
clobTokenIdsRaw == null -> null
else -> {
// 解析 JSON 字符串
parseStringArray(clobTokenIdsRaw)
}
}
// 解析 outcomes(可能是 JSON 字符串或数组)
val outcomes = parseStringArray(market.outcomes)
// 查找 tokenId 在 clobTokenIds 中的索引
val outcomeIndex = clobTokenIds?.indexOfFirst { token ->
token.equals(tokenId, ignoreCase = true)
}?.takeIf { it >= 0 }
// 获取 outcome 名称
val outcome = if (outcomeIndex != null && outcomes.isNotEmpty() && outcomeIndex < outcomes.size) {
outcomes[outcomeIndex]
} else {
null
}
val conditionId = market.conditionId ?: return null
MarketInfo(
conditionId = conditionId,
outcomeIndex = outcomeIndex,
outcome = outcome
)
} catch (e: Exception) {
logger.warn("查询市场信息失败: tokenId=$tokenId, error=${e.message}")
null
}
}
/**
* 获取区块时间戳
*/
suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
return try {
val blockRequest = JsonRpcRequest(
method = "eth_getBlockByNumber",
params = listOf(blockNumber, false)
)
val blockResponse = rpcApi.call(blockRequest)
if (blockResponse.isSuccessful && blockResponse.body() != null) {
val blockRpcResponse = blockResponse.body()!!
if (blockRpcResponse.error == null && blockRpcResponse.result != null) {
val blockJson = blockRpcResponse.result.asJsonObject
val timestampHex = blockJson.get("timestamp")?.asString
if (timestampHex != null) {
BigInteger(timestampHex.removePrefix("0x"), 16).toLong() * 1000 // 转换为毫秒
} else {
null
}
} else {
null
}
} else {
null
}
} catch (e: Exception) {
logger.warn("获取区块时间戳失败: blockNumber=$blockNumber, error=${e.message}")
null
}
}
/**
* 工具函数:地址转 topic(32字节,左对齐)
*/
fun addressToTopic32(address: String): String {
val clean = address.removePrefix("0x").lowercase()
return "0x" + clean.padStart(64, '0')
}
/**
* 工具函数:topic 转地址
*/
fun topicToAddress(topic: String): String {
val clean = topic.removePrefix("0x").lowercase()
return "0x" + clean.takeLast(40)
}
/**
* 工具函数:十六进制转 BigInteger
*/
fun hexToBigInt(hex: String): BigInteger {
val clean = hex.removePrefix("0x")
return if (clean.isBlank()) BigInteger.ZERO else BigInteger(clean, 16)
}
/**
* 工具函数:十六进制转字节数组
*/
fun bytesFromHex(hex: String): ByteArray {
val clean = hex.removePrefix("0x")
return ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
/**
* 工具函数:从字节数组切片 BigInteger(32字节)
*/
fun sliceBigInt32(bytes: ByteArray, offset: Int): BigInteger {
if (offset + 32 > bytes.size) return BigInteger.ZERO
val slice = bytes.sliceArray(offset until offset + 32)
return BigInteger(1, slice)
}
}
@@ -0,0 +1,565 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.service.system.RpcNodeService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.getProxyConfig
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 统一的链上 WebSocket 服务
* 管理唯一的 WebSocket 连接,其他服务通过订阅的方式接收链上事件
*/
@Service
class UnifiedOnChainWsService(
private val rpcNodeService: RpcNodeService,
private val retrofitFactory: RetrofitFactory,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java)
@Value("\${copy.trading.onchain.ws.reconnect.delay:3000}")
private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// WebSocket 连接(唯一)
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID)
private var requestIdCounter = 0
// 连接任务(确保只有一个连接任务在运行)
private var connectionJob: Job? = null
// 存储所有订阅:subscriptionId -> 订阅信息
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
// 用于在收到日志通知时,知道是哪个订阅
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
/**
* 订阅信息
*/
data class SubscriptionInfo(
val subscriptionId: String, // 订阅的唯一标识
val address: String, // 要监听的地址(Leader 地址或账户代理地址)
val entityType: String, // 实体类型:LEADER 或 ACCOUNT
val entityId: Long, // 实体 IDLeader ID 或 Account ID
val callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit // 回调函数
)
/**
* 订阅地址监听
* @param subscriptionId 订阅的唯一标识(建议格式:"{entityType}_{entityId}"
* @param address 要监听的地址(Leader 地址或账户代理地址)
* @param entityType 实体类型:LEADER 或 ACCOUNT
* @param entityId 实体 IDLeader ID 或 Account ID
* @param callback 回调函数,当检测到该地址的交易时调用
* @return 是否订阅成功
*/
fun subscribe(
subscriptionId: String,
address: String,
entityType: String,
entityId: Long,
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
): Boolean {
try {
// 如果已经订阅,先取消
if (subscriptions.containsKey(subscriptionId)) {
unsubscribe(subscriptionId)
}
// 创建订阅信息
val subscription = SubscriptionInfo(
subscriptionId = subscriptionId,
address = address.lowercase(),
entityType = entityType,
entityId = entityId,
callback = callback
)
subscriptions[subscriptionId] = subscription
// 如果已连接,立即订阅
if (isConnected) {
scope.launch {
subscribeAddress(subscription)
}
} else {
// 如果未连接,启动连接
startConnection()
}
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
return true
} catch (e: Exception) {
logger.error("订阅地址监听失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
return false
}
}
/**
* 取消订阅
*/
fun unsubscribe(subscriptionId: String) {
val subscription = subscriptions.remove(subscriptionId)
if (subscription != null && isConnected) {
// 取消该订阅的所有 RPC 订阅
scope.launch {
// 查找该订阅的所有 RPC subscriptionId
val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries
.filter { it.value == subscriptionId }
.map { it.key }
for (rpcSubId in rpcSubscriptionIds) {
unsubscribeRpc(rpcSubId)
rpcSubscriptionIdToSubscriptionId.remove(rpcSubId)
}
}
logger.info("取消订阅: subscriptionId=$subscriptionId")
}
// 如果没有订阅了,停止连接
if (subscriptions.isEmpty()) {
stop()
}
}
/**
* 启动连接(如果还没有连接)
*/
private fun startConnection() {
// 如果没有订阅,不启动连接
if (subscriptions.isEmpty()) {
return
}
// 如果连接任务已经在运行,不重复启动
if (connectionJob != null && connectionJob!!.isActive) {
return
}
// 启动连接任务
connectionJob = scope.launch {
startConnectionLoop()
}
}
/**
* 启动连接循环
*/
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
// 如果没有订阅,停止连接
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止连接")
stop()
break
}
// 如果已经连接,等待断开
if (isConnected && webSocket != null) {
waitForDisconnect()
continue
}
// 获取可用的 RPC 节点
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
if (wsUrl.isBlank() || httpUrl.isBlank()) {
logger.warn("没有可用的 RPC 节点,等待重试...")
delay(reconnectDelay)
continue
}
logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)")
// 创建 HTTP 客户端(用于 RPC 调用)
val httpClient = createHttpClient()
// 创建 RPC API 客户端
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
// 连接 WebSocket
connectWebSocket(wsUrl, httpClient, rpcApi)
// 等待连接建立
waitForConnect()
// 如果连接成功,订阅所有地址
if (isConnected) {
logger.info("WebSocket 连接已建立,开始订阅")
for (subscription in subscriptions.values) {
subscribeAddress(subscription)
}
// 等待连接断开
waitForDisconnect()
}
// 连接断开后,如果没有订阅了,不再重连
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止重连")
break
}
// 等待后重连
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
logger.error("连接异常: ${e.message}", e)
delay(reconnectDelay)
}
}
}
/**
* 创建 HTTP 客户端
*/
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) {
builder.proxy(proxy)
}
return builder.build()
}
/**
* 连接 WebSocket
*/
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
// 先关闭旧连接
webSocket?.close(1000, "重新连接")
webSocket = null
isConnected = false
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("链上 WebSocket 连接成功")
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch {
handleMessage(text, httpClient, rpcApi)
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch {
handleMessage(bytes.utf8(), httpClient, rpcApi)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
}
})
}
/**
* 等待连接建立
*/
private suspend fun waitForConnect() {
var waited = 0L
val timeout = 15000L // 15秒超时
while (!isConnected && waited < timeout) {
delay(100)
waited += 100
}
if (!isConnected) {
logger.warn("WebSocket 连接超时,等待重连")
}
}
/**
* 等待连接断开
*/
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
/**
* 订阅地址(为每个地址订阅 6 个事件)
*/
private suspend fun subscribeAddress(subscription: SubscriptionInfo) {
if (webSocket == null || !isConnected) {
return
}
val address = subscription.address
val walletTopic = OnChainWsUtils.addressToTopic32(address)
val subscriptionId = subscription.subscriptionId
try {
// 订阅 USDC Transfer (from wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId)
// 订阅 USDC Transfer (to wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId)
logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address")
} catch (e: Exception) {
logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
}
}
/**
* 订阅日志
*/
private fun subscribeLogs(address: String, topics: List<String?>, subscriptionId: String) {
val ws = webSocket ?: return
val params = mapOf(
"address" to address.lowercase(),
"topics" to topics.filterNotNull()
)
val requestId = ++requestIdCounter
requestIdToSubscriptionId[requestId] = subscriptionId
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_subscribe",
"params" to listOf("logs", params)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 取消 RPC 订阅
*/
private fun unsubscribeRpc(rpcSubscriptionId: String) {
val ws = webSocket ?: return
val requestId = ++requestIdCounter
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_unsubscribe",
"params" to listOf(rpcSubscriptionId)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 处理 WebSocket 消息
*/
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
val message = gson.fromJson(text, JsonObject::class.java)
// 处理订阅响应
if (message.has("result") && message.has("id")) {
val requestId = message.get("id")?.asInt
val rpcSubscriptionId = message.get("result")?.asString
if (requestId != null && rpcSubscriptionId != null) {
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
if (subscriptionId != null) {
// 保存 RPC subscriptionId 到订阅的映射
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
}
}
return
}
// 处理日志通知
if (message.has("params")) {
val params = message.getAsJsonObject("params")
val subscriptionIdParam = params.get("subscription")?.asString
val result = params.getAsJsonObject("result")
if (result != null) {
val txHash = result.get("transactionHash")?.asString
if (txHash != null && subscriptionIdParam != null) {
// 根据 RPC subscriptionId 找到对应的订阅
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
if (subscriptionId != null) {
// 处理交易,分发给对应的订阅者
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
} else {
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
processTransaction(txHash, httpClient, rpcApi)
}
}
}
}
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
}
}
/**
* 处理交易(为特定订阅)
* 直接调用订阅的回调
*/
private suspend fun processTransactionForSubscription(
txHash: String,
subscriptionId: String,
httpClient: OkHttpClient,
rpcApi: EthereumRpcApi
) {
val subscription = subscriptions[subscriptionId] ?: return
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
}
}
/**
* 处理交易(为所有订阅,用于兼容)
* 解析交易中的 Transfer 事件,分发给所有订阅者
*/
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 为每个订阅检查是否匹配,如果匹配则调用回调
for (subscription in subscriptions.values) {
val address = subscription.address
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
val isInvolved = erc20Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
} || erc1155Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
}
if (isInvolved) {
// 该地址参与了交易,调用回调
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
}
}
}
} catch (e: Exception) {
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
}
}
/**
* 停止连接
*/
fun stop() {
connectionJob?.cancel()
connectionJob = null
// 关闭 WebSocket 连接
webSocket?.close(1000, "停止监听")
webSocket = null
isConnected = false
// 清空订阅信息
subscriptions.clear()
requestIdToSubscriptionId.clear()
rpcSubscriptionIdToSubscriptionId.clear()
}
@PostConstruct
fun init() {
// 服务启动时不自动连接,等待有订阅时再连接
logger.info("统一链上 WebSocket 服务已初始化")
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.orders
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
@@ -13,6 +14,11 @@ import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -26,10 +32,10 @@ class OrderPushService(
private val objectMapper: ObjectMapper,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证)
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val copyOrderTrackingRepository: com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: com.wrbug.polymarketbot.repository.CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: com.wrbug.polymarketbot.repository.LeaderRepository? = null // 可选,避免循环依赖
private val cryptoUtils: CryptoUtils,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@@ -152,9 +158,9 @@ class OrderPushService(
return account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null &&
account.apiKey!!.isNotBlank() &&
account.apiSecret!!.isNotBlank() &&
account.apiPassphrase!!.isNotBlank()
account.apiKey.isNotBlank() &&
account.apiSecret.isNotBlank() &&
account.apiPassphrase.isNotBlank()
}
/**
@@ -378,7 +384,7 @@ class OrderPushService(
/**
* 获取订单详情
* 通过 PolymarketClobService 获取订单详情
* 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情
*/
private suspend fun fetchOrderDetail(
account: Account,
@@ -391,7 +397,7 @@ class OrderPushService(
return null
}
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证)
// 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情(需要 L2 认证)
// 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -453,7 +459,7 @@ class OrderPushService(
* 使用 /markets 接口通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID "0x..."
*/
private suspend fun fetchMarketInfo(conditionId: String): com.wrbug.polymarketbot.api.MarketResponse? {
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
return try {
// 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi()
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.orders
import com.wrbug.polymarketbot.api.SignedOrderObject
import com.wrbug.polymarketbot.util.toSafeBigDecimal
@@ -77,10 +77,6 @@ class OrderSigningService {
): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal()
// 舍入价格
val roundedPrice = roundNormal(priceDecimal, roundConfig.price)
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// makerAmount 是 USDC 金额,最多 2 位小数
@@ -104,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,使用原始价格计算(不使用舍入后的价格)
@@ -113,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())
}
@@ -176,25 +172,15 @@ class OrderSigningService {
// 5. 确保 maker 地址也是小写格式
val makerAddressLower = makerAddress.lowercase()
// 打印签名前的订单参数
logger.info("========== 订单签名前参数 ==========")
logger.info("订单方向: $side")
logger.info("价格: $price")
logger.info("数量: $size")
logger.info("Token ID: $tokenId")
logger.info("Maker 地址: $makerAddressLower")
logger.info("Signer 地址: $signerAddress")
logger.info("Taker 地址: $taker")
logger.info("Maker Amount (wei): ${amounts.makerAmount}")
logger.info("Taker Amount (wei): ${amounts.takerAmount}")
logger.info("Salt: $salt")
logger.info("Expiration: $expiration")
logger.info("Nonce: $nonce")
logger.info("Fee Rate BPS: $feeRateBps")
logger.info("Signature Type: $signatureType")
logger.info("Exchange Contract: $EXCHANGE_CONTRACT")
logger.info("Chain ID: $CHAIN_ID")
logger.info("====================================")
// 打印签名前的订单参数DEBUG 级别,避免敏感信息泄露)
logger.debug("========== 订单签名前参数 ==========")
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
logger.debug("Token ID: $tokenId")
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名
val signature = signOrder(
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.*
@@ -13,6 +13,8 @@ import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.BlockchainService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
@@ -30,7 +32,7 @@ class CopyTradingStatisticsService(
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val accountService: AccountService,
private val blockchainService: com.wrbug.polymarketbot.service.BlockchainService
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -69,7 +71,10 @@ class CopyTradingStatisticsService(
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
// 9. 构建响应
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
// 11. 构建响应
val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId,
accountId = copyTrading.accountId,
@@ -85,7 +90,7 @@ class CopyTradingStatisticsService(
totalSellOrders = statistics.totalSellOrders,
totalSellAmount = statistics.totalSellAmount,
currentPositionQuantity = statistics.currentPositionQuantity,
currentPositionValue = calculatePositionValue(statistics.currentPositionQuantity, currentPrice),
currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl,
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
@@ -105,7 +110,7 @@ class CopyTradingStatisticsService(
fun getOrderList(request: OrderTrackingRequest): Result<OrderListResponse> {
return try {
// 1. 验证跟单关系
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 根据类型查询
@@ -293,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()
// 持仓统计
@@ -334,9 +341,9 @@ class CopyTradingStatisticsService(
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
val result = accountService.getMarketPrice(marketId, outcomeIndex)
result.onSuccess { response ->
// 使用中间价,如果没有则使用最后价格
val price = response.midpoint ?: response.lastPrice
if (price != null) {
// 使用当前价格
val price = response.currentPrice
if (price.isNotBlank() && price != "0") {
// 使用 "marketId:outcomeIndex" 作为 key
val key = "$marketId:$outcomeIndex"
prices[key] = price
@@ -354,7 +361,7 @@ class CopyTradingStatisticsService(
* 获取链上实际持仓
* (marketId, outcomeIndex) 组合返回实际持仓数量
*/
private suspend fun getActualPositions(account: com.wrbug.polymarketbot.entity.Account?): Map<String, BigDecimal> {
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
val positions = mutableMapOf<String, BigDecimal>()
if (account == null || account.proxyAddress.isBlank()) {
@@ -385,6 +392,7 @@ class CopyTradingStatisticsService(
/**
* 计算未实现盈亏
* 使用链上实际持仓数量而不是 remainingQuantity考虑手动卖出的情况
* 按市场聚合订单计算加权平均买入价格避免重复计算
*/
private fun calculateUnrealizedPnl(
buyOrders: List<CopyOrderTracking>,
@@ -393,6 +401,9 @@ class CopyTradingStatisticsService(
): String {
var totalUnrealizedPnl = BigDecimal.ZERO
// 按市场聚合订单,计算加权平均买入价格
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
for (order in buyOrders) {
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
if (order.outcomeIndex == null) {
@@ -403,8 +414,32 @@ class CopyTradingStatisticsService(
// 使用 "marketId:outcomeIndex" 作为 key
val key = "${order.marketId}:${order.outcomeIndex}"
// 获取链上实际持仓数量(如果存在),否则使用 remainingQuantity
val actualQty = actualPositions[key] ?: order.remainingQuantity.toSafeBigDecimal()
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
val orderQty = order.remainingQuantity.toSafeBigDecimal()
// 如果订单持仓 <= 0,跳过
if (orderQty.lte(BigDecimal.ZERO)) continue
val buyPrice = order.price.toSafeBigDecimal()
val orderCost = orderQty.multi(buyPrice)
// 聚合同一市场的订单
val existing = marketAggregates[key]
if (existing != null) {
val totalQty = existing.first.add(orderQty)
val totalCost = existing.second.add(orderCost)
marketAggregates[key] = Pair(totalQty, totalCost)
} else {
marketAggregates[key] = Pair(orderQty, orderCost)
}
}
// 计算每个市场的未实现盈亏
for ((key, aggregate) in marketAggregates) {
val (totalQty, totalCost) = aggregate
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
val actualQty = actualPositions[key] ?: totalQty
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
if (actualQty.lte(BigDecimal.ZERO)) continue
@@ -413,9 +448,15 @@ class CopyTradingStatisticsService(
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
val buyPrice = order.price.toSafeBigDecimal()
// 使用实际持仓数量计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(actualQty)
// 计算加权平均买入价格
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
totalCost.div(totalQty)
} else {
continue
}
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
}
@@ -424,11 +465,44 @@ class CopyTradingStatisticsService(
/**
* 计算持仓价值
* 使用链上实际持仓数量和当前市场价格计算
* 按市场聚合避免重复计算
*/
private fun calculatePositionValue(positionQuantity: String, currentPrices: Map<String, String>): String {
// 这里简化处理,实际应该根据每个市场的持仓分别计算
// 暂时返回0,因为需要知道每个市场的持仓数量
return "0"
private fun calculatePositionValue(
buyOrders: List<CopyOrderTracking>,
currentPrices: Map<String, String>,
actualPositions: Map<String, BigDecimal>
): String {
var totalPositionValue = BigDecimal.ZERO
// 按市场聚合,获取所有不同的市场
val marketKeys = buyOrders
.filter { it.outcomeIndex != null }
.map { "${it.marketId}:${it.outcomeIndex}" }
.distinct()
for (key in marketKeys) {
// 获取链上实际持仓数量(如果存在)
val actualQty = actualPositions[key]
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
val totalQty = actualQty ?: buyOrders
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 如果持仓 <= 0,跳过
if (totalQty.lte(BigDecimal.ZERO)) continue
// 获取当前市场价格
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
// 计算持仓价值:持仓数量 × 当前价格
val positionValue = totalQty.multi(currentPrice)
totalPositionValue = totalPositionValue.add(positionValue)
}
return totalPositionValue.toString()
}
/**
@@ -0,0 +1,887 @@
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. 检查30秒前创建的订单,如果未成交则删除
checkAndDeleteUnfilledOrders()
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
updatePendingSellOrderPrices()
// 4. 更新买入订单的实际数据并发送通知
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)
}
}
/**
* 检查30秒前创建的订单,如果未成交则删除
* 首次检测但加入缓存中30s后还没有成交,则删除
*/
@Transactional
private suspend fun checkAndDeleteUnfilledOrders() {
try {
// 计算30秒前的时间戳
val thirtySecondsAgo = System.currentTimeMillis() - 30000
// 查询30秒前创建的订单
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
if (ordersToCheck.isEmpty()) {
return
}
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
// 按账户分组,避免重复创建 API 客户端
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
for ((accountId, orders) in ordersByAccount) {
try {
// 获取账户
val account = accountRepository.findById(accountId).orElse(null)
if (account == null) {
logger.warn("账户不存在,跳过检查: accountId=$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
)
// 检查每个订单
for (order in orders) {
try {
// 查询订单详情
val orderResponse = clobApi.getOrder(order.buyOrderId)
// 先检查 HTTP 状态码,非 200 的都跳过
if (orderResponse.code() != 200) {
// HTTP 非 200,记录日志并跳过,等待下次轮询
// 不删除订单,因为可能是临时网络问题或 API 错误
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("订单查询失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
continue
}
// HTTP 200,检查响应体
// 响应体也可能返回字符串 "null"Gson 解析时会返回 null
val orderDetail = orderResponse.body()
if (orderDetail == null) {
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
continue
}
// 订单不存在且未部分卖出,删除本地订单
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
continue
}
// 检查订单是否成交
// 如果订单状态不是 FILLED 且已成交数量为0,说明未成交,删除
val sizeMatched = orderDetail.sizeMatched?.toSafeBigDecimal() ?: BigDecimal.ZERO
if (orderDetail.status != "FILLED" && sizeMatched <= BigDecimal.ZERO) {
logger.info("订单30秒后仍未成交,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除未成交订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除未成交订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
} else {
logger.debug("订单已成交或部分成交,保留: orderId=${order.buyOrderId}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
}
} catch (e: Exception) {
logger.error("检查订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查账户订单失败: accountId=$accountId, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查未成交订单异常: ${e.message}", e)
}
}
/**
* 更新待更新的卖出订单价格
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
*/
@Transactional
private suspend fun updatePendingSellOrderPrices() {
try {
// 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理)
val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse()
if (pendingRecords.isEmpty()) {
return
}
logger.debug("找到 ${pendingRecords.size} 条待更新价格的卖出订单")
for (record in pendingRecords) {
try {
// 获取跟单关系
val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null)
if (copyTrading == null) {
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${record.copyTradingId}")
continue
}
// 获取账户
val account = accountRepository.findById(copyTrading.accountId).orElse(null)
if (account == null) {
logger.warn("账户不存在,跳过更新: accountId=${copyTrading.accountId}")
continue
}
// 检查账户是否配置了 API 凭证
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}")
continue
}
// 解密 API 凭证
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret!!)
} catch (e: Exception) {
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
continue
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase!!)
} catch (e: Exception) {
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
continue
}
// 创建带认证的 CLOB API 客户端
val clobApi = retrofitFactory.createClobApi(
account.apiKey!!,
apiSecret,
apiPassphrase,
account.walletAddress
)
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则不发送通知
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
if (!isAutoOrder) {
// 非自动订单,发送通知(使用临时数据)
sendSellOrderNotification(
record = record,
useTemporaryData = true,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
} else {
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
}
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
val updatedRecord = SellMatchRecord(
id = record.id,
copyTradingId = record.copyTradingId,
sellOrderId = record.sellOrderId,
leaderSellTradeId = record.leaderSellTradeId,
marketId = record.marketId,
side = record.side,
outcomeIndex = record.outcomeIndex,
totalMatchedQuantity = record.totalMatchedQuantity,
sellPrice = record.sellPrice,
totalRealizedPnl = record.totalRealizedPnl,
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
createdAt = record.createdAt
)
sellMatchRecordRepository.save(updatedRecord)
continue
}
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则跳过发送通知
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
if (isAutoOrder) {
logger.debug("自动生成的订单,跳过发送通知并直接标记为已处理: orderId=${record.sellOrderId}")
// 直接标记为已处理,不发送通知
val updatedRecord = SellMatchRecord(
id = record.id,
copyTradingId = record.copyTradingId,
sellOrderId = record.sellOrderId,
leaderSellTradeId = record.leaderSellTradeId,
marketId = record.marketId,
side = record.side,
outcomeIndex = record.outcomeIndex,
totalMatchedQuantity = record.totalMatchedQuantity,
sellPrice = record.sellPrice,
totalRealizedPnl = record.totalRealizedPnl,
priceUpdated = true, // 标记为已处理
createdAt = record.createdAt
)
sellMatchRecordRepository.save(updatedRecord)
continue
}
// 查询订单详情,获取实际成交价
val actualSellPrice = trackingService.getActualExecutionPrice(
orderId = record.sellOrderId,
clobApi = clobApi,
fallbackPrice = record.sellPrice
)
// 如果价格已更新(与当前价格不同),更新数据库
if (actualSellPrice != record.sellPrice) {
// 重新计算盈亏
val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!)
var totalRealizedPnl = BigDecimal.ZERO
for (detail in details) {
val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity)
// 更新明细的卖出价格和盈亏
// 注意:SellMatchDetail 的字段都是 val,需要创建新对象
val updatedDetail = SellMatchDetail(
id = detail.id,
matchRecordId = detail.matchRecordId,
trackingId = detail.trackingId,
buyOrderId = detail.buyOrderId,
matchedQuantity = detail.matchedQuantity,
buyPrice = detail.buyPrice,
sellPrice = actualSellPrice, // 更新卖出价格
realizedPnl = updatedRealizedPnl, // 更新盈亏
createdAt = detail.createdAt
)
sellMatchDetailRepository.save(updatedDetail)
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
}
// 发送通知(使用实际价格)
sendSellOrderNotification(
record = record,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
// 更新卖出记录
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
val updatedRecord = SellMatchRecord(
id = record.id,
copyTradingId = record.copyTradingId,
sellOrderId = record.sellOrderId,
leaderSellTradeId = record.leaderSellTradeId,
marketId = record.marketId,
side = record.side,
outcomeIndex = record.outcomeIndex,
totalMatchedQuantity = record.totalMatchedQuantity,
sellPrice = actualSellPrice, // 更新卖出价格
totalRealizedPnl = totalRealizedPnl, // 更新总盈亏
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
createdAt = record.createdAt
)
sellMatchRecordRepository.save(updatedRecord)
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
} else {
// 价格相同,但已经查询过,发送通知并标记为已处理
sendSellOrderNotification(
record = record,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
val updatedRecord = SellMatchRecord(
id = record.id,
copyTradingId = record.copyTradingId,
sellOrderId = record.sellOrderId,
leaderSellTradeId = record.leaderSellTradeId,
marketId = record.marketId,
side = record.side,
outcomeIndex = record.outcomeIndex,
totalMatchedQuantity = record.totalMatchedQuantity,
sellPrice = record.sellPrice,
totalRealizedPnl = record.totalRealizedPnl,
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
createdAt = record.createdAt
)
sellMatchRecordRepository.save(updatedRecord)
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
}
} catch (e: Exception) {
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
// 继续处理下一条记录
}
}
} catch (e: Exception) {
logger.error("更新待更新卖出订单价格异常: ${e.message}", e)
}
}
/**
* 更新待发送通知的买入订单
* 查询订单详情获取实际价格和数量,然后发送通知并更新数据库
*/
@Transactional
private suspend fun updatePendingBuyOrders() {
try {
// 查询所有未发送通知的买入订单
val pendingOrders = copyOrderTrackingRepository.findByNotificationSentFalse()
if (pendingOrders.isEmpty()) {
return
}
logger.debug("找到 ${pendingOrders.size} 条待发送通知的买入订单")
for (order in pendingOrders) {
try {
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
if (!isValidOrderId(order.buyOrderId)) {
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
// 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知
val updatedOrder = CopyOrderTracking(
id = order.id,
copyTradingId = order.copyTradingId,
accountId = order.accountId,
leaderId = order.leaderId,
marketId = order.marketId,
side = order.side,
outcomeIndex = order.outcomeIndex,
buyOrderId = order.buyOrderId,
leaderBuyTradeId = order.leaderBuyTradeId,
quantity = order.quantity,
price = order.price,
matchedQuantity = order.matchedQuantity,
remainingQuantity = order.remainingQuantity,
status = order.status,
notificationSent = true, // 标记为已发送通知
createdAt = order.createdAt,
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
continue
}
// 获取跟单关系
val copyTrading = copyTradingRepository.findById(order.copyTradingId).orElse(null)
if (copyTrading == null) {
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${order.copyTradingId}")
continue
}
// 获取账户
val account = accountRepository.findById(order.accountId).orElse(null)
if (account == null) {
logger.warn("账户不存在,跳过更新: accountId=${order.accountId}")
continue
}
// 检查账户是否配置了 API 凭证
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}")
continue
}
// 解密 API 凭证
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret!!)
} catch (e: Exception) {
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
continue
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase!!)
} catch (e: Exception) {
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
continue
}
// 创建带认证的 CLOB API 客户端
val clobApi = retrofitFactory.createClobApi(
account.apiKey!!,
apiSecret,
apiPassphrase,
account.walletAddress
)
// 查询订单详情
val orderResponse = clobApi.getOrder(order.buyOrderId)
// 先检查 HTTP 状态码,非 200 的都跳过
if (orderResponse.code() != 200) {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("查询订单详情失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
continue
}
// HTTP 200,检查响应体
// 响应体也可能返回字符串 "null"Gson 解析时会返回 null
val orderDetail = orderResponse.body()
if (orderDetail == null) {
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
continue
}
// 订单不存在且未部分卖出,删除本地订单
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
continue
}
// 获取实际价格和数量
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)
}
}
}
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.copytrading.templates
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CopyTradingTemplate
@@ -61,7 +61,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
@@ -120,7 +119,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
updatedAt = System.currentTimeMillis()
@@ -187,7 +185,6 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: sourceTemplate.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
)
@@ -261,7 +258,6 @@ class CopyTradingTemplateService(
supportSell = template.supportSell,
minOrderDepth = template.minOrderDepth?.toPlainString(),
maxSpread = template.maxSpread?.toPlainString(),
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
minPrice = template.minPrice?.toPlainString(),
maxPrice = template.maxPrice?.toPlainString(),
createdAt = template.createdAt,
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
@@ -12,6 +12,8 @@ import org.springframework.beans.BeansException
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@@ -26,12 +28,11 @@ class ApiHealthCheckService(
private val dataApiBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String,
@Value("\${polygon.rpc.url:}")
private val polygonRpcUrl: String,
@Value("\${polymarket.rtds.ws-url}")
private val polymarketWsUrl: String,
@Value("\${polymarket.builder.relayer-url:}")
private val builderRelayerUrl: String
private val builderRelayerUrl: String,
private val rpcNodeService: RpcNodeService
) : ApplicationContextAware {
private var applicationContext: ApplicationContext? = null
@@ -184,19 +185,12 @@ class ApiHealthCheckService(
/**
* 检查 Polygon RPC
* 使用动态获取的可用节点RpcNodeService 总是返回一个有效的 URL包括默认节点
*/
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
if (polygonRpcUrl.isBlank()) {
return@withContext ApiHealthCheckDto(
name = "Polygon RPC",
url = "未配置",
status = "skipped",
message = "未配置 Polygon RPC URL"
)
}
val url = polygonRpcUrl
checkJsonRpcApi("Polygon RPC", url)
// 使用 RpcNodeService 获取可用节点(总是返回有效值,包括默认节点)
val rpcUrl = rpcNodeService.getHttpUrl()
checkJsonRpcApi("Polygon RPC", rpcUrl)
}
/**
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.*
@@ -1,8 +1,10 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.ProxyConfig
import com.wrbug.polymarketbot.repository.ProxyConfigRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.util.ProxyConfigProvider
import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier
import com.wrbug.polymarketbot.util.createSSLSocketFactory
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
@@ -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,
@@ -337,15 +333,10 @@ class RelayClientService(
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe Address: $proxyAddress")
logger.debug("From Address: $fromAddress")
logger.debug("To: ${safeTx.to}")
logger.debug("Data: $redeemCallData")
logger.debug("Nonce: $proxyNonce")
logger.debug("Packed Signature: $packedSignature")
logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)")
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
logger.debug("Signature Length: ${packedSignature.length}")
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
@@ -368,13 +359,7 @@ class RelayClientService(
metadata = "Redeem positions via Builder Relayer"
)
logger.debug("Request Type: ${request.type}")
logger.debug("Request From: ${request.from}")
logger.debug("Request To: ${request.to}")
logger.debug("Request ProxyWallet: ${request.proxyWallet}")
logger.debug("Request Data Length: ${request.data.length}")
logger.debug("Request Signature Length: ${request.signature.length}")
logger.debug("Request Nonce: ${request.nonce}")
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
// 调用 Builder Relayer API(认证头通过拦截器添加)
val response = relayerApi.submitTransaction(request)
@@ -461,13 +446,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 +644,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 +668,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 +692,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 +763,7 @@ class RelayClientService(
}
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
return Result.success(txHash)
return Result.success(txHash.asString)
}
/**
@@ -802,10 +781,10 @@ class RelayClientService(
safeTxs: List<SafeTransaction>
): Result<String> {
// 批量执行:将多个交易合并为一个 execTransaction 调用
// 当前实现:委托给 BlockchainService
// 当前实现:委托给 com.wrbug.polymarketbot.service.common.BlockchainService
return Result.failure(
UnsupportedOperationException(
"批量 Gasless 执行暂未实现。请使用 BlockchainService.redeemPositions() 方法。"
"批量 Gasless 执行暂未实现。请使用 com.wrbug.polymarketbot.service.common.BlockchainService.redeemPositions() 方法。"
)
)
}
@@ -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
)
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.SystemConfigDto
import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest
@@ -25,6 +25,19 @@ class SystemConfigService(
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
/**
* 遮蔽敏感信息仅显示前4位和后4位
* 例如abcd1234...wxyz5678
*/
fun maskSensitiveValue(value: String?): String? {
if (value == null) return null
return when {
value.length <= 8 -> "****" // 太短则完全遮蔽
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
else -> "${value.take(4)}...${value.takeLast(4)}"
}
}
}
/**
@@ -36,10 +49,38 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置)
val builderApiKeyDisplay = builderApiKey?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
}
val builderSecretDisplay = builderSecret?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
}
val builderPassphraseDisplay = builderPassphrase?.let {
try {
maskSensitiveValue(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
)
}
@@ -1,11 +1,13 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.dto.NotificationConfigData
import com.wrbug.polymarketbot.dto.TelegramConfigData
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.DateUtils
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@@ -63,9 +65,12 @@ 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: com.wrbug.polymarketbot.api.PolymarketClobApi? = null,
clobApi: PolymarketClobApi? = null,
apiKey: String? = null,
apiSecret: String? = null,
apiPassphrase: String? = null,
@@ -82,35 +87,49 @@ 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 作为订单数量
actualSide = order.side // 使用订单详情中的 side
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
if (orderResponse.isSuccessful) {
val order = orderResponse.body()
if (order != null) {
if (actualPrice == null) {
actualPrice = order.price
}
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
if (actualOutcome == null) {
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
}
} else {
logger.debug("查询订单详情失败: 响应体为空, orderId=$orderId")
}
} else {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
}
} catch (e: Exception) {
logger.warn("查询订单详情失败,使用默认值: ${e.message}", e)
logger.warn("查询订单详情失败: orderId=$orderId, ${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)
@@ -124,8 +143,8 @@ class TelegramNotificationService(
marketSlug = marketSlug,
side = actualSide,
outcome = actualOutcome,
price = price,
size = size,
price = finalPrice,
size = finalSize,
amount = amount,
accountName = accountName,
walletAddress = walletAddress,
@@ -294,20 +313,10 @@ class TelegramNotificationService(
else -> filterType
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
@@ -357,13 +366,17 @@ class TelegramNotificationService(
""
}
// 格式化价格和数量
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """🚫 <b>$orderFiltered</b>
📊 <b>$orderInfo</b>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo
@@ -568,6 +581,68 @@ class TelegramNotificationService(
}
}
/**
* 格式化价格显示保留最多4位小数截断不四舍五入
*/
private fun formatPrice(price: String): String {
return try {
val priceDecimal = price.toSafeBigDecimal()
val formatted = if (priceDecimal.scale() > 4) {
priceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
priceDecimal.stripTrailingZeros()
}
formatted.toPlainString()
} catch (e: Exception) {
price
}
}
/**
* 格式化数量显示保留最多2位小数截断不四舍五入
*/
private fun formatQuantity(quantity: String): String {
return try {
val quantityDecimal = quantity.toSafeBigDecimal()
val formatted = if (quantityDecimal.scale() > 2) {
quantityDecimal.setScale(2, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
quantityDecimal.stripTrailingZeros()
}
formatted.toPlainString()
} catch (e: Exception) {
quantity
}
}
/**
* 构建账户信息显示格式账户名(钱包地址)
*/
private fun buildAccountInfo(
accountName: String?,
walletAddress: String?,
unknownAccount: String
): String {
return when {
!accountName.isNullOrBlank() && !walletAddress.isNullOrBlank() -> {
// 有账户名和钱包地址:账户名(钱包地址)
"${accountName}(${maskAddress(walletAddress)})"
}
!accountName.isNullOrBlank() -> {
// 只有账户名
accountName
}
!walletAddress.isNullOrBlank() -> {
// 只有钱包地址
maskAddress(walletAddress)
}
else -> {
// 都没有
unknownAccount
}
}
}
/**
* 构建订单成功消息
*/
@@ -611,18 +686,8 @@ class TelegramNotificationService(
else -> side
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
// 构建跟单信息(如果有)
val copyTradingInfo = mutableListOf<String>()
@@ -638,7 +703,7 @@ class TelegramNotificationService(
""
}
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
@@ -692,14 +757,18 @@ class TelegramNotificationService(
""
}
// 格式化价格和数量
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """✅ <b>$orderCreatedSuccess</b>
📊 <b>$orderInfo</b>
$orderIdLabel: <code>${orderId ?: unknown}</code>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
@@ -746,20 +815,10 @@ class TelegramNotificationService(
else -> side
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 错误信息已经是后端返回的 msg,不需要截断(但为了安全,限制长度)
val shortErrorMessage = if (errorMessage.length > 500) {
@@ -816,13 +875,17 @@ class TelegramNotificationService(
""
}
// 格式化价格和数量
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """❌ <b>$orderCreatedFailed</b>
📊 <b>$orderInfo</b>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo
@@ -887,20 +950,10 @@ class TelegramNotificationService(
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
val time = DateUtils.formatDateTime()
// 转义 HTML 特殊字符
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
@@ -921,12 +974,7 @@ class TelegramNotificationService(
// 构建仓位列表
val positionsText = positions.joinToString("\n") { position ->
val quantityDisplay = try {
val quantityDecimal = position.quantity.toSafeBigDecimal()
quantityDecimal.stripTrailingZeros().toPlainString()
} catch (e: Exception) {
position.quantity
}
val quantityDisplay = formatQuantity(position.quantity)
val valueDisplay = try {
val valueDecimal = position.value.toSafeBigDecimal()
val formatted = if (valueDecimal.scale() > 4) {
@@ -1,4 +1,4 @@
package com.wrbug.polymarketbot.service
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.UserCreateRequest
import com.wrbug.polymarketbot.dto.UserDto
@@ -1,6 +1,8 @@
package com.wrbug.polymarketbot.util
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
@@ -15,6 +17,13 @@ object DateUtils {
*/
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
/**
* 使用系统时区的日期时间格式化器(用于显示)
* 格式:yyyy-MM-dd HH:mm:ss
*/
private val displayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
/**
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
@@ -61,5 +70,19 @@ object DateUtils {
null
}
}
/**
* 将时间戳(毫秒)格式化为可读的日期时间字符串(使用系统时区)
* @param timestamp 时间戳(毫秒),如果为 null 则使用当前时间
* @return 格式化的日期时间字符串,格式:yyyy-MM-dd HH:mm:ss
*/
fun formatDateTime(timestamp: Long? = null): String {
val instant = if (timestamp != null) {
Instant.ofEpochMilli(timestamp)
} else {
Instant.now()
}
return displayFormatter.format(instant)
}
}
@@ -19,7 +19,7 @@ object EthereumUtils {
* @return 函数选择器,例如 "0x12345678"
*/
fun getFunctionSelector(functionSignature: String): String {
val hash = keccak256(functionSignature.toByteArray())
val hash = keccak256Hex(functionSignature.toByteArray())
return "0x" + hash.substring(0, 8)
}
@@ -77,16 +77,109 @@ object EthereumUtils {
return BigInteger(cleanHex, 16)
}
/**
* 从 ABI 编码的响应中解析 uint256 数组
* ABI 编码格式:
* - offset (32 bytes): 数组数据的位置偏移量
* - length (32 bytes): 数组长度
* - data: 每个元素 32 字节
* @param hexResult 十六进制结果(完整的 ABI 编码响应)
* @param offset 数组数据的偏移位置(字节数,从 offset 位置开始读取)
* @return BigInteger 数组
*/
fun decodeUint256Array(hexResult: String, offset: Int = 0): List<BigInteger> {
val cleanHex = hexResult.removePrefix("0x")
if (cleanHex.length < (offset + 1) * 64) {
return emptyList()
}
// 从 offset 位置开始读取
val startPos = offset * 64 // 每个 uint256 是 64 个十六进制字符
val lengthHex = cleanHex.substring(startPos, startPos + 64)
val length = BigInteger(lengthHex, 16).toInt()
if (length <= 0 || length > 100) { // 防止异常数据
return emptyList()
}
val result = mutableListOf<BigInteger>()
for (i in 0 until length) {
val elementStart = startPos + 64 + (i * 64) // 跳过长度字段
if (elementStart + 64 > cleanHex.length) {
break
}
val elementHex = cleanHex.substring(elementStart, elementStart + 64)
result.add(BigInteger(elementHex, 16))
}
return result
}
/**
* 从 ABI 编码的元组响应中解析数据
* 用于解析 getCondition 返回的 (uint256 payoutDenominator, uint256[] payouts)
* @param hexResult 十六进制结果
* @return Pair<payoutDenominator, payouts>
*/
fun decodeConditionResult(hexResult: String): Pair<BigInteger, List<BigInteger>> {
val cleanHex = hexResult.removePrefix("0x")
// 第一个 32 字节:payoutDenominator
val payoutDenominatorHex = cleanHex.substring(0, 64)
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
// 第二个 32 字节:payouts 数组的偏移量(通常是 0x40 = 64 字节)
val offsetHex = cleanHex.substring(64, 128)
val offset = BigInteger(offsetHex, 16).toInt() / 32 // 转换为 32 字节单位
// 从 offset 位置解析数组
val payouts = decodeUint256Array(hexResult, offset)
return Pair(payoutDenominator, payouts)
}
/**
* 将十六进制字符串转换为字节数组
* @param hex 十六进制字符串(带或不带 0x 前缀)
* @return 字节数组
*/
fun hexToBytes(hex: String): ByteArray {
val cleanHex = hex.removePrefix("0x")
return ByteArray(cleanHex.length / 2) { i ->
cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
/**
* 将字节数组转换为十六进制字符串
* @param bytes 字节数组
* @return 十六进制字符串(带 0x 前缀)
*/
fun bytesToHex(bytes: ByteArray): String {
return "0x" + bytes.joinToString("") { "%02x".format(it) }
}
/**
* 计算 Keccak-256 哈希(Ethereum 标准)
* 使用 BouncyCastle 库实现真正的 Keccak-256
* @param data 输入数据
* @return 32 字节的哈希值
*/
private fun keccak256(data: ByteArray): String {
fun keccak256(data: ByteArray): ByteArray {
val digest = KeccakDigest(256)
digest.update(data, 0, data.size)
val hash = ByteArray(digest.digestSize)
digest.doFinal(hash, 0)
return hash.joinToString("") { "%02x".format(it) }
return hash
}
/**
* 计算 Keccak-256 哈希并返回十六进制字符串
* @param data 输入数据
* @return 十六进制哈希字符串
*/
fun keccak256Hex(data: ByteArray): String {
return keccak256(data).joinToString("") { "%02x".format(it) }
}
}
@@ -2,14 +2,16 @@ package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.springframework.stereotype.Component
/**
* JSON 工具类
* 用于解析 JSON 字符串
*/
object JsonUtils {
private val gson = Gson()
@Component
class JsonUtils(
private val gson: Gson
) {
/**
* 解析 JSON 字符串数组
@@ -26,21 +26,23 @@ fun BigDecimal.multi(value: Any): BigDecimal {
return BigDecimal.ZERO
}
/**
* BigDecimal除法扩展函数
* BigDecimal除法扩展函数(带精度和舍入模式)
* 安全地将BigDecimal与任意数值类型相除
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
* @param scale 精度(小数位数)
* @param roundingMode 舍入模式
* @return 除法结果,如果转换失败返回IllegalBigDecimal
*/
fun BigDecimal.div(value: Any): BigDecimal {
fun BigDecimal.div(value: Any, scale: Int = 18, roundingMode: RoundingMode = RoundingMode.HALF_UP): BigDecimal {
kotlin.runCatching {
if (value is BigDecimal) {
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
val divisor = when (value) {
is BigDecimal -> value
is BigInteger -> value.toBigDecimal()
else -> BigDecimal(value.toString())
}
if (value is BigInteger) {
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
return divide(divisor, scale, roundingMode)
}
return IllegalBigDecimal
}
@@ -1,37 +1,138 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
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
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import jakarta.annotation.PreDestroy
/**
* Retrofit 客户端工厂
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
*
* 注意:为了避免内存泄漏,本类会缓存和复用客户端实例
*/
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String
private val gammaBaseUrl: String,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
// 共享的 OkHttpClient(用于不需要认证的 API
private val sharedOkHttpClient: OkHttpClient by lazy {
createClient().build()
}
// 共享的 OkHttpClient(用于需要跟随重定向的 API)
private val sharedOkHttpClientWithRedirect: OkHttpClient by lazy {
createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
// 缓存 Gamma API 客户端(单例)
private val gammaApi: PolymarketGammaApi by lazy {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
}
// 缓存 Data API 客户端(单例)
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = "https://data-api.polymarket.com"
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(sharedOkHttpClientWithRedirect)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
}
// 缓存 GitHub API 客户端(单例)
private val githubApi: GitHubApi by lazy {
val baseUrl = "https://api.github.com"
// 添加拦截器,设置 Accept 头以获取 reactions 数据
val githubInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(githubInterceptor)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(GitHubApi::class.java)
}
// 缓存不带认证的 CLOB API 客户端(单例)
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
// 缓存带认证的 CLOB API 客户端:walletAddress -> PolymarketClobApi
// 注意:每个账户使用不同的 API Key,需要不同的客户端
private val clobApiCache = ConcurrentHashMap<String, PolymarketClobApi>()
// 缓存 RPC API 客户端:rpcUrl -> EthereumRpcApi
private val rpcApiCache = ConcurrentHashMap<String, EthereumRpcApi>()
// 缓存 Builder Relayer API 客户端:relayerUrl -> BuilderRelayerApi
private val builderRelayerApiCache = ConcurrentHashMap<String, BuilderRelayerApi>()
/**
* 创建带认证的 Polymarket CLOB API 客户端
* 按钱包地址缓存,避免重复创建
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
@@ -44,129 +145,169 @@ class RetrofitFactory(
apiPassphrase: String,
walletAddress: String
): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
// 使用钱包地址作为缓存键(每个账户使用不同的 API Key)
return clobApiCache.computeIfAbsent(walletAddress) {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
}
/**
* 创建不带认证的 Polymarket CLOB API 客户端
* 用于不需要认证的查询接口
* @return PolymarketClobApi 客户端
* @return PolymarketClobApi 客户端(单例)
*/
fun createClobApiWithoutAuth(): PolymarketClobApi {
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
return clobApiWithoutAuth
}
/**
* 创建 Ethereum RPC API 客户端
* 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL
* 如果 RPC 不可用,将抛出异常
* 按 RPC URL 缓存,避免重复创建
* @param rpcUrl RPC 节点 URL
* @return EthereumRpcApi 客户端
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
*/
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
val okHttpClient = createClient().build()
// 确保实际的 RPC URL 以 / 结尾
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
rpcUrl
} else {
"$rpcUrl/"
}
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(rpcUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EthereumRpcApi::class.java)
// 使用 RPC URL 作为缓存键
return rpcApiCache.computeIfAbsent(actualRpcUrl) {
// 验证 RPC 是否可用(仅在新创建时验证)
validateRpcAvailability(actualRpcUrl)
// 使用固定的 baseUrlRetrofit 要求 baseUrl 必须以 / 结尾)
val fixedBaseUrl = "https://polyrpc.polyhermes/"
// 创建 URL 替换拦截器
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
val okHttpClient = createClient()
.addInterceptor(urlReplaceInterceptor)
.build()
Retrofit.Builder()
.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) {
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,不需要认证
* @return PolymarketGammaApi 客户端
* @return PolymarketGammaApi 客户端(单例)
*/
fun createGammaApi(): PolymarketGammaApi {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
return gammaApi
}
/**
* 创建 Polymarket Data API 客户端
* Data API 是公开 API,不需要认证
* @return PolymarketDataApi 客户端
* @return PolymarketDataApi 客户端(单例)
*/
fun createDataApi(): PolymarketDataApi {
val baseUrl = "https://data-api.polymarket.com"
val okHttpClient = createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
return dataApi
}
/**
* 创建 Builder Relayer API 客户端
* 按 relayerUrl 缓存,避免重复创建
* @param relayerUrl Builder Relayer URL
* @param apiKey Builder API Key
* @param secret Builder Secret
@@ -185,57 +326,94 @@ class RetrofitFactory(
relayerUrl
}
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
// 使用 baseUrl 作为缓存键(注意:如果 API Key 变化,需要清理缓存)
return builderRelayerApiCache.computeIfAbsent(baseUrl) {
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
}
}
/**
* 创建 GitHub API 客户端
* GitHub API 是公开 API,不需要认证(但建议使用 token 提高速率限制)
* 添加 Accept 头以获取 reactions 数据
* @return GitHubApi 客户端
* @return GitHubApi 客户端(单例)
*/
fun createGitHubApi(): GitHubApi {
val baseUrl = "https://api.github.com"
return githubApi
}
/**
* 清理缓存(用于测试或配置变更时)
*/
@PreDestroy
fun destroy() {
logger.info("清理 RetrofitFactory 缓存")
clobApiCache.clear()
rpcApiCache.clear()
builderRelayerApiCache.clear()
}
/**
* 清理指定钱包地址的 CLOB API 缓存
* 用于 API Key 变更时
*/
fun clearClobApiCache(walletAddress: String) {
clobApiCache.remove(walletAddress)
logger.debug("已清理 CLOB API 缓存: $walletAddress")
}
/**
* 清理指定 RPC URL 的 RPC API 缓存
* 用于 RPC 节点变更时
*/
fun clearRpcApiCache(rpcUrl: String) {
val actualRpcUrl = if (rpcUrl.endsWith("/")) rpcUrl else "$rpcUrl/"
rpcApiCache.remove(actualRpcUrl)
logger.debug("已清理 RPC API 缓存: $actualRpcUrl")
}
}
/**
* 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
// 添加拦截器,设置 Accept 头以获取 reactions 数据
val githubInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.build()
return chain.proceed(request)
}
}
// 将固定 baseUrl 替换为实际的 RPC URL
val originalUrlString = originalUrl.toString()
val newUrlString = originalUrlString.replace(fixedBaseUrl, actualRpcUrl)
val okHttpClient = createClient()
.addInterceptor(githubInterceptor)
// 使用 HttpUrl 解析新 URL,确保格式正确
val newUrl = newUrlString.toHttpUrlOrNull()
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(GitHubApi::class.java)
return chain.proceed(newRequest)
}
}
@@ -259,22 +437,35 @@ class ResponseLoggingInterceptor : Interceptor {
val responseBody = response.peekBody(2048)
val responseBodyString = responseBody.string()
// 检查是否是有效的 JSON
val isJson = responseBodyString.trim().startsWith("{") ||
responseBodyString.trim().startsWith("[")
// 检查响应体是否为空
val isEmpty = responseBodyString.isBlank()
if (!isJson || !response.isSuccessful) {
// 检查是否是有效的 JSON
val trimmedBody = responseBodyString.trim()
val isJson = !isEmpty && (
trimmedBody.startsWith("{") ||
trimmedBody.startsWith("[")
)
// 如果响应体为空或不是 JSON,记录警告
if (isEmpty || !isJson) {
val bodyPreview = if (isEmpty) {
"(空响应体)"
} else {
trimmedBody.take(500)
}
logger.warn(
"API 响应异常: method=${request.method}, url=${request.url}, " +
"code=${response.code}, isJson=$isJson, " +
"responseBody=${responseBodyString.take(500)}"
"code=${response.code}, isJson=$isJson, isEmpty=$isEmpty, " +
"responseBody=$bodyPreview"
)
}
} catch (e: Exception) {
// 如果读取响应体失败,记录异常但不影响响应
logger.debug("读取响应体失败: ${e.message}")
}
}
return response
}
}
@@ -3,7 +3,7 @@ package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.WebSocketSubscriptionService
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
@@ -40,23 +40,12 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
polymarket.data-api.base-url=https://data-api.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
# Polygon RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# Builder Relayer 配置(用于 Gasless 交易)
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
# 仓位推送配置
# 轮询间隔(毫秒),默认3秒
position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000}
# 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接
position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000}
# 跟单轮询配置
# 轮询间隔(毫秒),默认2秒
copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
@@ -0,0 +1,9 @@
-- ============================================
-- V11: 添加最大仓位配置字段
-- 在 copy_trading 表中添加最大仓位金额和最大仓位数量配置
-- ============================================
ALTER TABLE copy_trading
ADD COLUMN max_position_value DECIMAL(20, 8) NULL COMMENT '最大仓位金额(USDC),NULL表示不启用' AFTER max_price,
ADD COLUMN max_position_count INT NULL COMMENT '最大仓位数量,NULL表示不启用' AFTER max_position_value;
@@ -0,0 +1,41 @@
-- 创建 RPC 节点配置表
CREATE TABLE rpc_node_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
provider_type VARCHAR(50) NOT NULL COMMENT '服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC',
name VARCHAR(100) NOT NULL COMMENT '节点名称',
http_url VARCHAR(500) NOT NULL COMMENT 'HTTP RPC URL',
ws_url VARCHAR(500) COMMENT 'WebSocket URL (可选)',
api_key VARCHAR(200) COMMENT 'API Key (加密存储)',
enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
priority INT DEFAULT 0 COMMENT '优先级(数字越小优先级越高)',
last_check_time BIGINT COMMENT '最后检查时间(毫秒时间戳)',
last_check_status VARCHAR(20) COMMENT '最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN',
response_time_ms INT COMMENT '最后一次响应时间(毫秒)',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
INDEX idx_enabled_priority (enabled, priority),
INDEX idx_last_check_status (last_check_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Polygon RPC 节点配置表';
-- 插入默认公共节点 (PublicNode)
INSERT INTO rpc_node_config (
provider_type,
name,
http_url,
ws_url,
enabled,
priority,
created_at,
updated_at,
last_check_status
) VALUES (
'PUBLIC',
'PublicNode (Default)',
'https://polygon.publicnode.com',
'wss://polygon.publicnode.com',
TRUE,
999,
UNIX_TIMESTAMP() * 1000,
UNIX_TIMESTAMP() * 1000,
'UNKNOWN'
);

Some files were not shown because too many files have changed in this diff Show More