Compare commits

..

112 Commits

Author SHA1 Message Date
WrBug f1ec0a330b feat(account): 按代理地址去重、默认账户名、前端重复提示与 DB 唯一约束
后端:
- 导入去重改为按 proxy_address(existsByProxyAddress),重复时返回 ACCOUNT_ALREADY_EXISTS(4601)
- 未填账户名时默认生成 SAFE/MAGIC-代理地址后4位(无中括号)
- Controller 识别 ACCOUNT_ALREADY_EXISTS 并返回对应错误码

前端:
- 导入失败时 message.error 提示;code=4601 时使用 accountImport.duplicateAccount 多语言
- store 抛出错误时附带 response.data.code 供表单判断

数据库 V33:
- 移除 wallet_address 唯一约束,新增 proxy_address 唯一约束
- 已存在账户的 wallet_type 统一更新为 safe

实体:
- Account.walletAddress 取消 unique,Account.proxyAddress 设为 unique

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:40:25 +08:00
WrBug 72de65d670 feat(frontend): 优化导入账户弹窗与文案
- 导入弹窗:增加步骤条、导入方式改为按钮、代理选项卡片紧凑展示
- 代理选项:标题改为「请选择账户类型」,地址完整显示,有资产标绿,小白说明放入卡片内
- 私钥/助记词:输入框默认两行、禁止换行(换行自动转空格/去除)
- 多语言:新增 proxyAddressHelp,更新 selectProxyOption 文案
- Modal 宽度与内边距微调;PositionList 移除未使用 useRef

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:23:29 +08:00
WrBug 3405a1cda3 feat: 钱包类型枚举与前端账号类型展示
后端:
- 新增 WalletType 枚举(MAGIC/SAFE),移除 safe/magic 字符串硬编码
- RelayClientService/BlockchainService/AccountService/OrderSigningService 使用枚举
- Builder Relayer API 类型使用常量 RELAYER_TYPE_PROXY/SAFE

前端:
- 账户列表、详情、导入 Modal 显示账号类型(Magic/Safe Tag)
- 导入账户 Modal 移除安全提示 Alert,移除 showAlert 与相关 i18n
- 移除无用 i18n key:securityTip、securityTipDesc、walletTypeMagic、walletTypeSafe、magicNotSupported
- 钱包类型 Tag 简化为仅显示 Magic 或 Safe

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:14:04 +08:00
WrBug fc6fa8b419 fix: 修复 Magic 账户赎回失败问题
主要修复:
1. 修复 PROXY ABI 编码问题:
   - 添加缺失的 tuple offset 字段(32字节)
   - 修正 data offset 从 192 改为 128(符合 ABI 规范)
   - 与 builder-relayer-client 保持一致

2. 添加 gas limit 动态估算:
   - 通过 eth_estimateGas 估算合理的 gas limit
   - 避免硬编码 10M 导致 Relay Hub gasleft() 检查失败
   - 估算失败时回退到默认值

3. 优化 API Key 检查逻辑:
   - 仅在涉及 Magic 账户时提前检查 Builder API Key
   - Safe 账户可在未配置 API Key 时使用手动交易

修复的问题:
- Magic 赎回交易失败:Not enough gasleft()
- PROXY 编码格式与官方不一致

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:59:39 +08:00
WrBug cf2c8a611c feat: 赎回批量执行与仓位页体验优化
- 赎回:同一账户多市场合并为一笔交易,减少 Relayer 调用次数
- 仓位页:可赎回统计静默刷新,赎回按钮不再常驻 loading
- 账户导入表单、API、多语言等相关改动

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:20:41 +08:00
WrBug bd323fca35 feat: 支持 Magic(邮箱/社交登录)账户导入和赎回
- 前端:启用 Magic 账户导入选项(移除 disabled 和"暂不支持"提示)
- 订单签名:根据钱包类型自动设置 signatureType(Magic=1, Safe=2)
- Builder Relayer API:添加 relay-payload 接口和 PROXY 请求类型支持
- RelayClientService:实现 Magic PROXY 赎回流程(Gasless,需 Builder API Key)
  - 获取 relay payload(relay 地址和 nonce)
  - 编码 ProxyFactory.proxy(calls) 调用数据
  - 创建并签名 PROXY 结构哈希
  - 提交 PROXY 类型交易到 Builder Relayer
- BlockchainService:redeemPositions 支持 walletType 参数
- AccountService:移除 Magic 账户赎回限制,传递 walletType 参数

Magic 账户赎回要求配置 Builder API Key(Gasless),Safe 账户保持原有逻辑不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:51:18 +08:00
WrBug a067a20a02 feat: 回测任务支持价格区间过滤
- 后端:
  - BacktestTask 实体类添加 minPrice 和 maxPrice 字段
  - BacktestDto 添加价格区间参数和配置字段
  - BacktestService 支持创建和查询价格区间配置
  - BacktestExecutionService 在 taskToCopyTrading 中设置价格区间
  - 添加数据库迁移脚本 V32__add_backtest_price_range_filter.sql

- 前端:
  - BacktestList 添加价格区间输入表单(最低价、最高价)
  - 详情页面显示价格区间配置
  - 一键创建跟单配置时包含价格区间
  - 添加中英繁三语言翻译
  - 修复价格输入框精度显示问题(0.4 不再显示为 0.40000000)

功能说明:
- 价格区间过滤与跟单配置保持一致,确保回测结果能准确反映实际跟单效果
- 支持只设置最低价、只设置最高价、或同时设置两者
- 留空表示不限制价格

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 23:54:49 +08:00
WrBug 99c4328830 feat(backtest): 失败与已停止的回测任务支持在列表中删除
操作列删除按钮对 STOPPED、FAILED 状态也显示,与详情页一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:57:37 +08:00
WrBug 1df438d18d fix(frontend): 修复 BacktestList 与 AddModal 的 TypeScript 类型错误
- BacktestList: Option 改为 Select.Option,避免与原生 HTML Option 冲突
- AddModal: onSelectChange 仅在 value 有值时调用 fetchLeaderAssetInfo

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:46:27 +08:00
WrBug 366d3d1b25 fix(positions): 修复仓位管理点击市场链接打开两个标签页的问题
在市场列链接的 onClick 中增加 e.preventDefault(),阻止 <a> 默认行为,
避免与 window.open() 重复打开同一页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:41:59 +08:00
WrBug 17a6a4f314 fix: 创建跟单时带入最大仓位金额参数
修复从回测任务创建跟单时,maxPositionValue 参数没有带入的问题。

修改内容:
- BacktestList.tsx:两处创建跟单配置时添加 maxPositionValue 字段
  1) handleCreateCopyTrading 函数(预填充配置)
  2) 复制按钮的预填充配置
- CopyTradingOrders/AddModal.tsx:
  1) AddModalProps 接口添加 maxPositionValue 字段
  2) fillPreFilledConfig 函数添加 maxPositionValue 处理

类型定义已存在:
- types/index.ts 的 CopyTradingCreateRequest 已包含 maxPositionValue 字段
- CopyTradingOrders/AddModal.tsx 的 handleSubmit 已处理该字段(第 355 行)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:40:12 +08:00
WrBug 8600449f69 fix(copytrading): 赎回后按 copyTrading 分别生成自动卖出记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:38:47 +08:00
WrBug 3d47bd50d9 chore(backtest): 移除回测轮询服务的冗余日志
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:31:26 +08:00
WrBug 20df471683 feat: 回测任务添加最大仓位参数支持
参考跟单配置的实现,为回测任务添加最大仓位金额限制功能。

后端改动:
- 添加数据库迁移文件 V31__add_backtest_max_position_value.sql
- BacktestTask 实体添加 maxPositionValue 字段(BigDecimal?,NULL表示不启用)
- BacktestCreateRequest 添加 maxPositionValue 字段
- BacktestConfigDto 添加 maxPositionValue 字段
- BacktestService 处理 maxPositionValue 的创建、详情和复制
- BacktestExecutionService.taskToCopyTrading 映射 maxPositionValue 参数
- BacktestExecutionService.executeBacktest 在买入逻辑中添加仓位检查
  * 检查条件:当前仓位 + 买入金额 <= 最大仓位
  * 仓位价值计算:quantity * avgPrice
  * 超过限制时跳过该笔买入并记录详细日志
  * 按市场+方向(marketId + outcomeIndex)分别检查

前端改动:
- BacktestList 表单添加最大仓位金额输入框(可选字段)
- 创建回测任务时包含 maxPositionValue
- 回测任务详情中显示 maxPositionValue(仅在配置了时显示)
- 默认最大每日订单数从 50 改为 100
- 多语言翻译新增:
  * backtest.maxPositionValue: 最大仓位金额 / 最大倉位金額 / Max Position Value
  * maxPositionValuePlaceholder: 留空表示不启用最大仓位限制

功能特点:
- 可选参数:留空或为 null 时表示不启用该限制,保持向后兼容
- 单市场单方向限制:按 marketId 和 outcomeIndex 分别计算和限制
- 精确计算:使用 BigDecimal 进行数值比较
- 详细日志:超过限制时记录当前仓位、买入金额、总计等详细信息

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:25:24 +08:00
WrBug 5487c7c862 feat(leader): Leader 管理展示跟单/回测数量并支持跳转筛选
- 后端:LeaderDto 增加 backtestCount,LeaderService 统计回测数并返回
- Leader 列表:展示跟单数、回测数,点击跳转跟单配置/回测页并带 leaderId 筛选
- 跟单配置页:从 URL 读取 leaderId 并应用筛选,Leader 下拉改用 LeaderSelect
- 回测页:支持 Leader 筛选,页面加载时请求 Leader 列表,筛选与详情统一显示规则
- LeaderSelect:支持 style,回测/跟单筛选统一使用
- 多语言:补充 leaderList 相关 key
- 移动端:Leader 管理卡片内按钮增加内边距 padding 8px 16px

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:03:17 +08:00
WrBug c1829fe21b feat(backtest): 结算显示市场名称,列表非终态任务 3s 轮询进度
- 结算记录: WIN/LOSE/UNKNOWN/CLOSED 通过 marketService.getMarket 写入 marketTitle
- 回测列表: 存在 PENDING/RUNNING 时每 3 秒静默刷新列表更新进度,全部终态后停止轮询

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 01:12:31 +08:00
WrBug b1ad6f02d2 feat(backtest): 回测结算持久化、按配置重新测试、执行时以当前时间为窗口基准
- 持久化: BUY/SELL 与 SETTLEMENT(WIN/LOSE/UNKNOWN/CLOSED) 均写入 backtest_trade
- 重新测试: 已完成任务支持「按当前配置重新测试」,新任务名称可编辑,后端 POST /tasks/rerun + 前端按钮与确认弹窗
- 回测窗口: 首次执行以当前时间为终点、startTime = endTime - backtestDays(局部变量,不修改实体)
- 新增错误码 BACKTEST_TASK_NOT_COMPLETED、SERVER_BACKTEST_RERUN_FAILED 及多语言

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 01:08:40 +08:00
WrBug 01a1836659 docs: 清理根目录临时文档
- 删除过时的版本发布说明(RELEASE.md 和 RELEASE_NOTES_v2.0.*.md)
- 删除临时检查报告(CHECK_AND_FIX_REPORT.md、IMPLEMENTATION_COMPLETE.md、MISSING_ITEMS_CHECK.md)
- 所有版本发布信息统一在 RELEASE_NOTES.md 中管理
- 添加新的部署指南文档和 Docker 构建配置

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:34:46 +08:00
WrBug b616e10ceb feat(skills): 添加 check-i18n-keys 技能并补全多语言 key
- 按 Cursor Agent Skills 规范:技能目录含 SKILL.md 与 scripts/
- 多语言检查脚本:扫描 t() 引用,对比 zh-CN/zh-TW/en common.json
- 补全缺失 key:common.create/copy/copySuccess/copyFailed、
  leaderList.openDetailFailed、leaderDetail.market、
  filteredOrdersList.orderDetails、filteredOrdersList.filterTypes.keywordFilter

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:17:27 +08:00
WrBug d07c4c2c5e fix: improve deployment info display
- Add admin reset password key to deployment completion info
- Fix color code display by adding -e flag to echo commands
- Display admin reset key in plaintext for easy reference
2026-02-01 03:17:58 +08:00
WrBug 7485e2ce24 feat: add interactive deployment script with one-liner installation
- Add deploy-interactive.sh: Interactive deployment script with automatic configuration
  - Auto-generate secure random keys (JWT, admin reset key, crypto key, DB password)
  - Smart .env detection for quick updates without reconfiguration
  - Random port generation (10000-60000) for SERVER_PORT default
  - Interactive prompts with clear defaults and auto-generation hints
  - Automatic docker-compose.prod.yml download from GitHub
  - Health check and deployment verification

- Add deploy-interactive-README.md: Comprehensive deployment guide
  - One-liner installation commands (curl/wget)
  - Quick update workflow for existing deployments
  - Detailed feature explanations and usage examples

- Update README.md & README_EN.md: Add one-liner installation section
  - Automatic working directory creation (~polyhermes)
  - Simplified deployment process for new users
  - Clear benefits and features list

Features:
- 📁 Auto-creates ~/polyhermes working directory
- 🔐 Generates secure random secrets automatically
-  Quick update mode: skip config if .env exists
- 🚀 One command from zero to running
- 🌐 Downloads latest docker-compose config from GitHub
-  Full health check and verification
2026-02-01 03:12:27 +08:00
WrBug 8f52b5016a feat: 将回测任务详情改为 Modal 方式展示
- 将任务详情从页面跳转改为 Modal 弹窗展示
- 在详情 Modal 中添加完整的统计信息、配置信息、图表和交易记录
- 修复统计数据显示:添加 totalTrades 字段,修复空值处理
- 将'创建跟单'按钮固定在 Modal 底部 footer
- 修复 Modal 滚动问题,确保内容可以正常滚动
- 修复编译错误:删除未使用的 navigate 导入
- 添加调试日志便于问题排查
2026-01-31 22:57:07 +08:00
WrBug 5c0808c2cb feat: 添加从回测任务创建跟单配置功能
- 在回测任务列表页和详情页添加'创建跟单'按钮
- 实现从回测任务配置自动填充到跟单配置表单
- 复用 AddModal 组件,支持 preFilledConfig 参数预填充
- 修复配置字段从 BacktestConfigDto 获取的问题
- 列表页点击按钮时先获取任务详情以获取配置信息
- 添加调试日志便于问题排查
- 更新多语言文案:'一键创建跟单配置' -> '创建跟单'
2026-01-31 22:23:44 +08:00
WrBug 9d01c120e5 fix: 修复回测任务恢复逻辑和分页问题
- 修复新建任务被误判为恢复任务的问题:将 lastProcessedTradeIndex 默认值从 0 改为 null
- 修复页码计算错误:统一页码从 0 开始,确保新任务的 offset 为 0
- 修复恢复任务时跳过已处理条目的逻辑
- 添加数据库迁移文件 V30,将现有新任务的索引值改为 NULL
2026-01-31 21:45:02 +08:00
WrBug ec8cfeac77 fix: 修复回测分页起始位置错误
- 修复新回测任务从第1页开始,导致 offset=100
- 应该从第0页开始,offset=0
- 修复后不会跳过前100条交易数据

Bug原因:
- BacktestExecutionService startPage 初始化为 1
- offset = page * size = 1 * 100 = 100
- 导致跳过前100条交易
2026-01-31 21:25:56 +08:00
WrBug 7c833f1e9b fix: 资金变化图始终显示所有交易数据
- 添加 allTrades 状态存储所有交易数据
- 添加 fetchAllTrades 方法获取所有数据(size=10000)
- 图表使用 allTrades 而不是分页的 trades
- 修复图表因分页只显示部分数据的问题
2026-01-31 21:15:09 +08:00
WrBug 08cc30a90f feat: 移除回测交易记录的盈亏列
- 移除盈亏列,进一步简化交易记录表格
- 盈亏信息已在统计卡片和图表中展示
2026-01-31 21:13:39 +08:00
WrBug 8288cd7579 feat: 移除回测交易记录的手续费列
- 移除手续费列,简化交易记录表格
- 手续费已包含在盈亏计算中,无需单独显示
2026-01-31 21:12:49 +08:00
WrBug 55da551971 fix: 修复回测统计计算空列表异常
- 修复 calculateStatistics 在 trades 为空时的 IndexOutOfBoundsException
- 添加空列表检查,避免访问 trades[0]
- 确保回测任务即使没有交易记录也能正常完成
2026-01-31 21:10:10 +08:00
WrBug ff3b24b50e fix: 优化回测交易记录显示
- 盈亏颜色逻辑:仅盈利(>0)显示绿色,亏损(<0)显示红色,持平(=0)无颜色
- 手续费显示:确保null值显示为 $0.00
- 遵守绿涨红跌原则
2026-01-31 20:58:13 +08:00
WrBug 88cae4018a fix: 修复 V29 migration SQL 语法兼容性
- 移除 DROP COLUMN IF EXISTS(仅 MySQL 8.0.29+ 支持)
- 使用标准 DROP COLUMN 语法(MySQL 5.7+ 兼容)
- 添加 Flyway 清理脚本
2026-01-31 20:51:26 +08:00
WrBug 96fbc3f720 feat: 回测系统优化 - 清理字段、移动端适配、缓存优化
## 主要改动

### 1. 回测字段清理
- 删除 BacktestCreate.tsx(未使用,创建使用 modal)
- 移除不适用于回测的字段:
  - priceTolerance, delaySeconds(回测使用历史数据)
  - minOrderDepth, maxSpread(无历史订单簿数据)
  - minPrice, maxPrice, maxPositionValue(回测中无实际意义)
  - maxMarketEndDate(意义不大)
- 更新相关 Entity、DTO、Service
- 添加数据库 migration(V29)

### 2. 移动端响应式适配
- BacktestList:筛选器、表格、Modal、表单响应式布局
- BacktestDetail:详情卡片、统计信息、按钮响应式
- 使用 useMediaQuery hook,断点 768px
- 支持手机、平板、桌面多种设备

### 3. 已结算市场缓存优化
- MarketPriceService 添加 Caffeine 缓存
- 缓存已结算市场价格,避免重复 RPC 调用
- 预计 RPC 请求减少 ~78.6%,性能提升显著
- 添加缓存统计和管理方法

### 4. UI 修复
- 修复 BacktestDetail 停止按钮文本
- 更新多语言翻译(supportSell → 跟单卖出)

## 性能收益
- 回测场景 RPC 调用减少 78.6%
- 回测执行时间预计减少 11秒(14秒 → 3秒)
- 移动端用户体验显著提升
2026-01-31 20:45:40 +08:00
WrBug fd25821e39 fix: 优化回测功能UI和修复问题
## 主要修改

### UI 优化
- 将回测功能移到跟单交易菜单下作为子菜单
- 创建回测改为使用 Modal 方式(移除独立页面)
- 优化导航路径,使用 useNavigate 替代 window.location.href

### 功能修复
- 修复 API 路径问题:移除重复的 /api 前缀(/api/backtest -> /backtest)
- 修复 copyRatio 输入框单位:改为百分比输入(100 表示 100%),与跟单配置保持一致
- 修复回测列表中 leader 列显示为空的问题:优先显示 leaderName,为空时显示 leaderAddress

### 多语言完善
- 补充缺失的多语言 key:
  - common.ascending / common.descending / common.day
  - backtest.sortBy / backtest.sortOrder / backtest.createdAt
  - backtest.copyRatioTooltip / backtest.copyRatioPlaceholder
  - 以及其他表单验证相关的 key
- 为中文简体、中文繁体、英文三种语言都添加了完整的翻译

## 修改的文件
- frontend/src/App.tsx - 移除 BacktestCreate 路由
- frontend/src/components/Layout.tsx - 调整菜单结构
- frontend/src/pages/BacktestList.tsx - Modal 创建、修复 leader 显示
- frontend/src/pages/BacktestDetail.tsx - 优化导航
- frontend/src/pages/BacktestCreate.tsx - 同步 copyRatio 修改
- frontend/src/services/api.ts - 修复 API 路径
- frontend/src/locales/* - 补充多语言配置
2026-01-31 07:46:16 +08:00
WrBug cdd02e9f3d feat: 实现回测功能
## 功能概述
实现完整的回测功能,支持基于历史数据模拟跟单策略的执行效果。

## 后端实现
- 数据库:新增 backtest_task 和 backtest_trade 表(V27迁移脚本)
- 实体类:BacktestTask、BacktestTrade
- Repository:BacktestTaskRepository、BacktestTradeRepository
- Service:
  - BacktestService:回测任务管理(CRUD)
  - BacktestDataService:从 Polymarket Data API 获取历史交易数据
  - BacktestExecutionService:回测算法核心实现
  - BacktestPollingService:定时轮询执行回测任务
- Controller:BacktestController(6个API接口)
- DTO:BacktestDto、TradeData
- 错误码:新增回测相关错误码和国际化消息

## 前端实现
- 页面组件:
  - BacktestList:回测任务列表
  - BacktestCreate:创建回测任务
  - BacktestDetail:回测详情(含图表)
  - BacktestChart:资金曲线图表(使用 ECharts)
- 类型定义:backtest.ts
- API 服务:集成所有回测接口
- 国际化:支持中英文

## 核心特性
- 回测天数限制:1-15 天
- 数据获取:直接从 Polymarket Data API 获取历史交易(不使用缓存表)
- 任务执行:同一时刻只执行一个任务,按创建时间顺序执行最早创建的任务
- 回测算法:完整实现市场结算、卖出匹配、价格容忍度、每日订单限制等规则
- 实时进度:支持任务进度更新和实时轮询

## 文档更新
- BACKTEST_PRD.md:产品需求文档
- BACKTEST_TECHNICAL_DESIGN.md:技术设计文档
- BACKTEST_REVIEW_CHECKLIST.md:设计评审检查清单

## 其他修改
- 移除 max_position_count 配置(V26迁移脚本)
- 移除 BacktestSyncService(不再需要实时同步)
- 修复前后端编译错误
2026-01-31 07:27:36 +08:00
WrBug fabbd81f22 Merge branch 'main' into backtest 2026-01-31 01:06:54 +08:00
WrBug ace32b37cf Merge pull request #26 from WrBug/remove-telegram-notify-workflow
chore: 移除 Telegram PR 合并通知工作流
2026-01-31 01:01:23 +08:00
WrBug f1eae8a40b chore: 移除 Telegram PR 合并通知工作流 2026-01-31 00:59:45 +08:00
WrBug ca655f351d docs: 添加 v2.0.3 发布说明 2026-01-31 00:57:45 +08:00
WrBug 40081c2464 feat: 为所有数值显示添加千分位分隔符
- 重构 formatNumber 和 formatUSDC 函数,默认添加千分位分隔符
- 更新 Statistics.tsx、CopyTradingStatistics.tsx 等统计页面
- 更新 PositionList.tsx 持仓列表的数值显示
- 更新 AccountList.tsx 账户列表的余额显示
- 所有数值(金额、数量、价格等)现在默认显示千分位
- 示例:1234567.89 显示为 1,234,567.89

影响范围:
- 工具函数:utils/index.ts
- 统计页面:Statistics.tsx, CopyTradingStatistics.tsx
- 业务页面:PositionList.tsx, AccountList.tsx

Changes:
- Refactored formatNumber and formatUSDC to include thousand separators by default
- Updated statistics pages to display numbers with commas
- Updated position list and account list for better readability
- Example: 1234567.89 now displays as 1,234,567.89
2026-01-31 00:52:42 +08:00
WrBug f1f809f54b docs: 新增跟单回测功能设计文档
- 新增产品需求文档 (BACKTEST_PRD.md)
- 新增技术设计文档 (BACKTEST_TECHNICAL_DESIGN.md)
- 新增设计审查清单 (BACKTEST_REVIEW_CHECKLIST.md)
- 新增文档总览 (README.md)

关键设计:
- 实时市场结算机制(按endDate检查)
- 严格余额检查(不计入未实现持仓)
- 停止条件优化(余额不足且无持仓时停止)
- 不计算手续费(简化逻辑)
2026-01-31 00:26:00 +08:00
WrBug e8fd1b503b 移除跟单最大仓位数量(maxPositionCount)配置
- 数据库:创建迁移文件 V26 删除 max_position_count 字段
- 后端:移除实体类、DTO、服务中的 maxPositionCount 相关代码
- 后端:移除 FilterResult 中的 FAILED_MAX_POSITION_COUNT 状态
- 后端:移除 CopyTradingFilterService 中的最大仓位数量检查逻辑
- 前端:移除类型定义、表单字段和国际化翻译
- 前端:移除过滤订单列表中的 MAX_POSITION_COUNT 类型
- 前端:修复编译错误(Spin 导入、Table fixed 类型、size 类型)
2026-01-31 00:24:25 +08:00
WrBug 390b3ee876 feat: 在创建跟单配置时显示Leader资产信息
- 选择Leader后自动获取并显示资产信息(总资产、可用余额、仓位资产)
- 在AddModal和EditModal中都实现该功能
- 使用Card和Statistic组件美观展示
- 添加中英文多语言支持
- 使用formatUSDC格式化显示金额
2026-01-30 23:58:18 +08:00
WrBug 80976609c7 fix: 修复TypeScript类型错误
- 移除未使用的PositionDto导入
- 修复pnl参数重复声明
2026-01-30 23:29:42 +08:00
WrBug 17eea0183f refactor: 列表只显示可用余额
- 桌面和移动端都只显示可用余额
- 移除总余额显示
2026-01-30 23:13:26 +08:00
WrBug 6980781f89 refactor: 移除仓位资产列
- 列表只显示总余额和跟单数
- 移除仓位资产列
- 移动端卡片移除仓位资产Tag
2026-01-30 22:42:44 +08:00
WrBug 3350039f05 feat: Leader列表显示仓位资产
- 列表新增仓位资产列
- 移动端卡片新增仓位资产Tag
- 文案'仓位余额'改为'仓位资产'
- 仓位资产使用紫色Tag显示
2026-01-30 22:24:59 +08:00
WrBug 0bdc0c74d1 feat: Leader列表优化
- 后端过滤价值为0的仓位
- 持仓列表显示市场名称而非ID
- 列表移除分类和创建时间列
- 文案'跟单关系数'改为'跟单数'
- 持仓DTO添加title字段
2026-01-30 22:03:50 +08:00
WrBug cccc829cef Merge pull request #25 from WrBug/dev
feat: 优化构建流程和通知机制
2026-01-29 04:09:44 +08:00
WrBug fa1a915e9c docs: 添加 v2.0.2 release notes 2026-01-29 04:00:11 +08:00
WrBug e5992b5145 feat(workflow): 优化构建流程和通知机制
- 添加 workflow_dispatch 支持手动触发构建
- Release 事件默认只打包产物(在线升级),不构建 Docker
- 手动触发可选择构建类型:在线升级或 Docker 升级
- 优化 Telegram 通知文案,区分构建类型和升级路径
- 在线升级任务不发送开始通知,只发送完成通知
- 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
2026-01-29 03:59:07 +08:00
WrBug 42472f6b03 fix: 修复买入订单金额精度问题
- 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
- makerAmount (USDC) 限制为最多 2 位小数
- takerAmount (shares) 限制为最多 4 位小数
- 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
2026-01-29 03:45:17 +08:00
WrBug c56e08e681 docs: 添加 v2.0.1 release notes 2026-01-28 15:30:20 +08:00
WrBug 7e87965418 fix: 修复订单状态检查中缓存清除导致计时重置的问题
- 修复订单详情为 null 时缓存清除时机问题,避免计时被重置导致订单无法删除
- 统一部分卖出和未成交订单的删除逻辑,都使用60秒重试窗口(之前部分卖出订单等待1小时)
- 删除未使用的常量 PARTIAL_SOLD_CLEANUP_WINDOW_MS
- 优化日志输出,区分部分卖出和未成交订单的日志信息

修复问题:
- 订单超过60秒不会删除的问题
- 部分卖出订单等待时间过长的问题
2026-01-28 15:28:36 +08:00
WrBug e115d459f7 refactor: 优化过滤原因文案的数值显示格式
优化内容:
1. 新增 DateUtils.formatDuration() 函数:将毫秒转换为可读格式(X天X小时X分钟)
2. 优化市场截止时间过滤原因:使用格式化后的时间显示,避免显示过长的毫秒数
3. 优化所有过滤原因中的 BigDecimal 数值显示:
   - 价格区间检查:去除尾随零
   - 价差检查:去除尾随零
   - 订单深度检查:去除尾随零
   - 仓位金额检查:去除尾随零

优化效果:
- 时间显示:1009163461ms (280小时) -> 11天16小时26分钟
- 数值显示:0.50000000000000000000 -> 0.5

影响范围:
- DateUtils.kt
- CopyTradingFilterService.kt
2026-01-28 15:16:12 +08:00
WrBug dd39e59304 fix: 修复禁用RPC节点后仍被使用的问题
问题描述:
- 用户在前端禁用节点后,系统仍然使用禁用的节点
- 原因:RetrofitFactory 的 rpcApiCache 未被清理,导致禁用的节点 URL 继续在拦截器中被使用

修复内容:
1. updateNode 方法:添加禁用节点时清理缓存的逻辑
2. deleteNode 方法:添加删除节点时清理缓存的逻辑
3. getAvailableNode 方法:优化逻辑,确保始终返回可用节点
   - 没有启用节点时返回默认节点
   - 所有节点不可用时返回默认节点
   - 异常时返回默认节点作为兜底
4. 新增 createDefaultNodeConfig 辅助方法:创建默认节点配置
5. 简化 getHttpUrl 和 getWsUrl 方法:移除冗余逻辑

影响范围:
- RpcNodeService.kt
2026-01-28 15:13:07 +08:00
WrBug 2efc04a3c3 fix: 补充缺失的多语言key
修复内容:
1. copyTradingOrders 部分:添加 allFullyMatched 和 partiallyMatched(zh-CN、zh-TW)
2. filteredOrdersList 部分:添加 noFilteredOrders(所有语言文件)

影响范围:
- frontend/src/locales/en/common.json
- frontend/src/locales/zh-CN/common.json
- frontend/src/locales/zh-TW/common.json
2026-01-28 15:11:19 +08:00
WrBug 2b20f4b2e2 fix: 修复订单金额计算和价格范围验证问题
修复了两个关键问题:

1. 订单金额精度错误
   - 原因:总是强制将金额舍入到 4 位小数,导致精度丢失
   - 问题案例:25.09488 被错误地舍入为 25.0948
   - 修复:只在小数位数超过 roundConfig.amount 时才进行舍入,采用与 clob-client 一致的条件舍入逻辑

2. 价格超出范围错误
   - 原因:价格 0.999 舍入到 2 位小数后变成 1.00,违反了 price < 1 的限制
   - 修复:在舍入后验证价格范围,超出时自动调整到最接近的有效值 (0.01-0.99)

主要变更:
- 对价格进行 roundNormal 舍入(参考 clob-client)
- 实现条件舍入逻辑:先 roundUp(amount + 4),必要时再 roundDown(amount)
- 添加价格范围验证 (MIN_PRICE=0.01, MAX_PRICE=0.99)
- 添加 decimalPlaces() 辅助函数
- 修改 roundNormal/roundDown/roundUp 函数,只有必要时才舍入

参考:clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
2026-01-23 12:54:48 +08:00
WrBug 419c68c024 fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
- 移除 Dockerfile 中无效的 COPY frontend/dist 指令
- 改进前端构建逻辑,添加外部产物检查
- 改进后端构建逻辑,支持复制外部构建的 JAR 文件
- 在 deploy.sh 中添加创建占位符目录步骤,避免 COPY 失败
- 保持与 GitHub Actions 构建流程的兼容性(BUILD_IN_DOCKER=false)

修复了本地使用 ./deploy.sh 部署时的构建错误:
"failed to calculate checksum: /frontend/dist: not found"
2026-01-21 21:04:25 +08:00
WrBug 8889803dd7 fix: 修复 Dockerfile 中前端产物复制逻辑
- 修复 BUILD_IN_DOCKER=true 时 frontend/dist 不存在的问题
- 当 BUILD_IN_DOCKER=true 时,RUN 已经创建了 dist 目录
- COPY frontend/dist ./dist 现在应该能正常工作
2026-01-21 20:56:57 +08:00
WrBug 9c5517768f Merge pull request #24 from WrBug/dynamic_load
feat: 合并 v2.0.0 系统动态更新功能到 main 分支
2026-01-21 20:46:49 +08:00
WrBug 4aa85a9c2d fix: 修复系统更新 API 路由和改进健康检查逻辑
- 修复 /api/update/execute 404 错误,改为 /api/update/update
- 增加健康检查等待时间从30秒到90秒
- 后端日志输出到文件,便于诊断问题
- 每5秒检查一次后端进程状态
- 改进错误诊断信息,区分进程退出和健康检查失败
- 记录后端启动日志的最后100行用于问题排查
2026-01-21 11:53:30 +08:00
WrBug a77b3b10ee feat: 改进系统更新健康检查逻辑
- 增加等待时间从30秒到90秒
- 后端日志输出到文件,便于诊断问题
- 每5秒检查一次后端进程状态
- 改进错误诊断信息,区分进程退出和健康检查失败
- 记录后端启动日志的最后100行用于问题排查
2026-01-21 11:31:19 +08:00
WrBug 532f4c3e25 fix: 修复系统更新 API 路由错误(/update/execute -> /update/update) 2026-01-21 05:30:48 +08:00
WrBug 04629e73b6 fix: 修复 TypeScript 编译错误
- 移除未使用的 getGitHubTagUrl 导入
- 移除未使用的 Divider、InfoCircleOutlined 导入
- 移除未使用的 Typography 组件解构
2026-01-21 05:18:42 +08:00
WrBug 696193c571 feat: 优化系统更新功能
- 系统更新模块移到系统设置页面最上方
- 版本号显示使用 Tag 格式(gitTag)
- 版本号 Tag 根据是否有新版本显示不同颜色(黄色=有新版本,绿色=无新版本)
- 版本号使用镂空样式,字号 8px
- 支持 Markdown 渲染更新内容
- 美化系统更新页面样式
- 在 Layout 中添加版本更新检查,有新版本时显示提示
2026-01-21 05:13:28 +08:00
WrBug 38e256c4fb fix: 修复前端构建时版本号未正确传递的问题
- 在 GitHub Actions 前端构建步骤中添加 VERSION、GIT_TAG 环境变量
- 确保版本号能正确注入到前端构建产物中
2026-01-21 04:52:32 +08:00
WrBug 64a78406ed fix: 修复 Docker 构建时找不到前端产物的问题
- 从 .dockerignore 移除 frontend/dist 和 backend/build,使外部构建产物可被 Docker 使用
- 在 GitHub Actions 中添加构建上下文准备步骤,验证产物存在
- 更新 Dockerfile 注释,说明构建产物在不同场景下的使用
2026-01-21 04:16:01 +08:00
WrBug 59297ec52f fix: 修复 Dockerfile 在 BUILD_IN_DOCKER=false 时找不到前端产物的问题
- 在构建阶段先从构建上下文复制外部产物
- 如果 BUILD_IN_DOCKER=true,编译会覆盖外部产物
- 如果 BUILD_IN_DOCKER=false,使用已复制的外部产物
2026-01-21 04:06:23 +08:00
WrBug 0ff6832dc1 fix: 修复前端 TypeScript 编译错误
- 导出 apiClient 供 SystemUpdate 组件使用
- 删除 SystemSettings 中未使用的系统更新相关代码
- 系统更新功能已独立到 SystemUpdate 组件
2026-01-21 03:53:01 +08:00
WrBug 56a8928631 fix: 添加 gradle-wrapper.jar 的例外规则到 .gitignore
- 确保 gradle-wrapper.jar 不被 *.jar 规则忽略
- Gradle Wrapper 的 JAR 文件必须提交到仓库
2026-01-21 03:47:18 +08:00
WrBug 266c6d595d fix: 强制添加 gradle-wrapper.jar 到版本控制
- gradle-wrapper.jar 是 Gradle Wrapper 必需的文件
- 必须提交到仓库才能在其他环境中使用
- 修复 GitHub Actions 构建错误
2026-01-21 03:47:01 +08:00
WrBug b2e0816968 fix: 添加 gradle-wrapper.jar 到版本控制
- 修复 .gitignore 中错误忽略 gradle-wrapper.jar
- Gradle Wrapper 的 JAR 文件应该被提交到仓库
- 修复 GitHub Actions 构建时找不到 Wrapper 的问题
2026-01-21 03:46:43 +08:00
WrBug dc47583565 fix: 修复 GitHub Actions 构建错误,使用 Gradle Wrapper
- 将 gradle 命令改为 ./gradlew
- 确保 gradlew 有执行权限
- 修复 Pre-release v2.0.0-beta 构建失败问题
2026-01-21 03:43:42 +08:00
WrBug 26dd3bb387 feat: 实现系统动态更新功能和 Release 创建脚本
主要变更:

1. 动态更新功能
   - 新增 Python 更新服务 (docker/update-service.py)
   - 添加系统更新前端页面 (frontend/src/pages/SystemUpdate.tsx)
   - 配置 Nginx 代理更新服务 API
   - 更新 Docker 启动脚本支持多进程管理
   - 修复权限验证接口 (AuthController.verify)

2. Release 创建脚本
   - 新增 create-release.sh 脚本支持快速创建 GitHub Release
   - 支持自动拼接 -beta 后缀(pre-release)
   - 支持无交互模式(--yes 参数)
   - 添加详细的使用文档

3. GitHub Actions 增强
   - 添加更新包构建和上传流程
   - 支持 Pre-release 检测和过滤

4. 文档完善
   - 添加动态更新技术方案文档
   - 添加 Docker 版本号确定流程文档
   - 添加 Release 脚本使用说明
2026-01-21 03:34:16 +08:00
WrBug fabfe601c6 Merge pull request #23 from WrBug/dev
Release v1.1.16: 订单列表优化和多语言修复
2026-01-20 23:32:31 +08:00
WrBug 662aa47de6 refactor: 优化匹配关系列表的订单ID显示
- 将'卖出订单ID'和'买入订单ID'两列合并为一列'订单ID'
- 第一行显示买入订单ID,第二行显示卖出订单ID
- 添加'买入'和'卖出'标签区分两种订单ID
- 保持复制按钮功能
- 优化移动端和桌面端的显示效果
- 桌面端列宽从120+180调整为150+200以容纳更多内容
2026-01-20 23:19:05 +08:00
WrBug c3d9d10d5d fix: 修复卖出订单标签页的多语言键值错误
- 修复分组标签使用错误的键值:allFullyMatched -> allFullySold
- 修复筛选器选项使用错误的键值:statusPartiallyMatched -> partiallySold,statusFullyMatched -> allFullySold
- 确保中文环境显示正确的中文文案:'全部成交' -> '全部卖出','部分成交' -> '部分卖出'
- 与买入订单标签页的键值使用保持一致
2026-01-20 23:11:54 +08:00
WrBug 9926533049 fix: 修复多语言配置中的键值重复和缺失问题
- 修复 copyTradingOrders 对象中重复的键值
- 统一键名规范:allFullyMatched -> allFullySold, partiallyMatched -> partiallySold
- 添加缺失的 filterMarketTitle 键值
- 确保中英文和繁体中文的键值配置一致
- 修复所有语言文件的 JSON 格式(添加换行符)
2026-01-20 22:57:17 +08:00
WrBug 4d72017b97 fix: 优化匹配关系列表页的显示和交互
- 将'查询'按钮改为'刷新'按钮,文案更准确
- 在表格中新增'市场'列,显示市场标题和ID
- 为移动端和桌面端的市场标题添加跳转到 Polymarket 的链接
- 导入 getPolymarketUrl 工具函数支持市场跳转
- 优化用户体验,便于快速查看市场详情
2026-01-20 11:48:14 +08:00
WrBug 45734c051e fix: 优化买入订单分组的卖出状态文案显示
- 当完全成交数为0时,显示'未卖出'而不是'部分成交 (0/X)'
- 统一状态文案:'部分成交'改为'部分卖出','全部成交'改为'全部卖出'
- 更新中英文和繁体中文的多语言配置
- 优化筛选选项中的状态文案,与分组显示保持一致
2026-01-20 11:44:33 +08:00
WrBug db8471bb16 fix: 统一卖出订单和买入订单的市场分组排序规则
- 修改卖出订单的市场分组排序逻辑
- 按照该市场最近的卖出订单时间倒序排序(与买入订单保持一致)
- 替换原有的按订单数量倒序排序规则
- 优化分组显示的用户体验,便于快速定位活跃市场
2026-01-20 11:38:41 +08:00
WrBug 0dc6f5894f refactor: 优化最大仓位价值检查逻辑,按市场+方向分别限制
- 修改 maxPositionValue 检查逻辑,从按市场累计改为按市场+方向(outcomeIndex)分别计算
- 新增 Repository 方法 sumCurrentPositionValueByMarketAndOutcomeIndex 支持按方向统计
- 移除未使用的 sumCurrentPositionValueByMarket 和 existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan 方法
- 更新 checkPositionLimits 方法,增加 outcomeIndex 参数
- 更新 checkFilters 方法,传递 outcomeIndex 参数
- 更新日志和注释,明确说明按市场+方向检查仓位

说明:同一市场的 YES 和 NO 方向现在分别受 maxPositionValue 限制,不再累加计算
2026-01-20 11:15:38 +08:00
WrBug c9769aa17a fix: 修复比例模式计算数量错误
- 移除 copyRatio 多余的除以 100 操作
- copyRatio 字段存储的是倍数值(如 1.3 表示 130%),不需要再转换
- 影响:买入数量计算、固定金额模式卖出数量计算
- 修复示例:配置 130% 后,Leader 买入 6.25 份,跟单数量应为 8.125 而不是 0.08125
2026-01-20 10:45:03 +08:00
WrBug 07b4d654b4 清理 MarketPollingService 调试日志
- 删除多余的 debug 日志输出
2026-01-19 12:50:34 +08:00
WrBug b65827038f 添加订单详情查询脚本
- 添加 get-order-detail.js 脚本,用于获取 Polymarket 订单详情
- 添加 package.json 配置文件
- 忽略 package-lock.json (已在 .gitignore 中通过 node_modules/ 规则处理)
2026-01-19 12:50:20 +08:00
WrBug d768da72c6 清理 MarketPollingService 中多余的 debug 日志 2026-01-19 12:48:18 +08:00
WrBug 7385efff1a 优化订单详情为null时的处理逻辑
- 当订单详情为null且已发送通知超过60秒时,将订单状态改为fully_matched而不是删除
- 避免已经通过订单详情更正并发送通知的订单被意外删除
- fully_matched状态的订单不会被包含在待查询列表中,自动跳过查询
2026-01-19 12:47:03 +08:00
WrBug 3e2e97e572 fix: 修复 CopyOrderTrackingService 和 CopyTradingService 的 @Transactional 自调用问题
1. CopyOrderTrackingService 修复
   - 实现 ApplicationContextAware 接口
   - 添加 getSelf() 方法获取代理对象
   - 在 processTrade() 中通过代理调用 processBuyTrade() 和 processSellTrade()
   - 确保两个子方法的 @Transactional 生效

2. CopyTradingService 修复
   - 实现 ApplicationContextAware 接口
   - 添加 getSelf() 方法获取代理对象
   - 在 updateCopyTradingStatus() 中通过代理调用 updateCopyTrading()
   - 确保内部调用的 @Transactional 生效

修改的文件:
- CopyOrderTrackingService.kt
- CopyTradingService.kt
2026-01-16 10:28:39 +08:00
WrBug ae68a33c1e fix: 优化仓位列表分页和统计功能
- 添加完整的分页功能,支持移动端和桌面端
- 移动端:自定义分页控件,支持切换每页 10/20/50 条
- 桌面端:使用 Ant Design Table 分页,支持切换每页 10/20/50 条
- 优化仓位统计逻辑,区分当前仓位和历史仓位
- 当前仓位:显示浮动盈亏和已实现盈亏
- 历史仓位:移除盈亏统计显示
- 筛选条件变化时自动重置到第一页
2026-01-16 10:14:54 +08:00
WrBug deea59fdbf fix: 修复 OrderStatusUpdateService 的事务和并发问题
1. 解决 @Transactional 自调用问题
   - 实现 ApplicationContextAware 接口
   - 通过代理对象调用 @Transactional 方法,确保事务生效

2. 防止定时任务并发执行
   - 添加 updateJob 跟踪上一次任务状态
   - 如果上一次任务还在执行,跳过本次执行
   - 避免多个更新任务同时运行导致的数据竞争

修改的方法:
- updateOrderStatus(): 添加并发控制
- cleanupDeletedAccountOrders(): 通过代理对象调用
- checkAndDeleteUnfilledOrders(): 通过代理对象调用
- updatePendingSellOrderPrices(): 通过代理对象调用
- updatePendingBuyOrders(): 通过代理对象调用
2026-01-15 15:21:29 +08:00
WrBug b90f86b081 fix: 修复 @Transactional 方法的可见性问题
将 OrderStatusUpdateService 中 4 个使用 @Transactional 的方法从 private 改为 public,
确保 Spring 能够正确代理这些方法。

修改的方法:
- cleanupDeletedAccountOrders()
- checkAndDeleteUnfilledOrders()
- updatePendingSellOrderPrices()
- updatePendingBuyOrders()

参考 UserService.kt 的实现模式,@Transactional 方法必须是 public 的。
2026-01-15 15:05:53 +08:00
WrBug f6f5866118 refactor: 清理 PositionPollingService 代码
- 移除多余的空行
- 移除调试日志
2026-01-15 04:35:11 +08:00
WrBug b1e69135b8 feat: 添加跟单订单来源字段
- 在 CopyOrderTracking 实体添加 source 字段,记录订单来源
- 支持 activity-ws(Polymarket WebSocket)和 onchain-ws(OnChain WebSocket)两种来源
- 更新 processBuyTrade 方法,接收并保存 source 参数
- 更新 OrderStatusUpdateService,保留原始 source 信息
- 添加数据库迁移脚本 V25
- 统一 CopyTradingWebSocketService 使用 activity-ws 作为来源
2026-01-15 04:33:55 +08:00
WrBug 0c7f34a28a fix: 添加事务注解修复删除操作异常
- 在 cleanupExpiredProcessedTrades 方法上添加 @Transactional 注解
- 修复 TransactionRequiredException 异常
- 删除操作必须在事务中执行
2026-01-15 04:19:46 +08:00
WrBug c53fcde5d7 feat: 添加已处理交易记录定时清理服务
- 创建 ProcessedTradeCleanupService 定时清理过期的去重记录
- 保留时间设置为10分钟(重复订单通常10秒后就不会出现)
- 清理间隔为10分钟,避免数据库空间浪费
- 记录清理日志,便于监控和调试
2026-01-15 04:17:54 +08:00
WrBug 81a620af12 feat(activity-ws): 同时监听 trades 和 orders_matched,添加 txHash 去重
- 订阅消息同时包含 trades 和 orders_matched 两种类型
- 添加 processedTxHashes Cache (LRU 100 条,10 分钟过期)
- 防止同一交易被重复处理
- 新增 duplicateTxHashMessages 统计指标
- 更新日志信息,明确标注监听范围
2026-01-15 04:13:27 +08:00
WrBug 5f44a0ca20 优化订单状态更新和统计服务
1. 订单详情为 null 且已部分卖出的订单超时清理
   - 新增 PARTIAL_SOLD_CLEANUP_WINDOW_MS 常量(1小时)
   - 超过1小时无法获取详情的已部分卖出订单自动清理
   - 避免无效订单长期占用数据库空间

2. 优化买入订单分组排序逻辑
   - 按最近买入订单时间排序,提升用户体验
   - 修复日志中的错误描述(卖出订单列表)

3. 代码格式化优化
2026-01-15 02:28:11 +08:00
WrBug 5d2cf945f3 fix: 修复 AccountOnChainMonitorService 中 JsonNull 导致的崩溃
- 增加 receiptRpcResponse.result.isJsonNull 检查
- 防止在链上查询返回 JsonNull 时调用 asJsonObject() 抛出 IllegalStateException
- 优化空值处理逻辑,确保程序安全退出
2026-01-15 02:10:49 +08:00
WrBug 227a38fa89 Merge branch 'main' into dev 2026-01-14 15:00:41 +08:00
WrBug abcc004606 fix: 添加订单查询失败的兜底逻辑,避免因网络异常误删订单
- 新增 orderNullDetectionTime 缓存,记录订单详情首次检测为 null 的时间戳
- 订单详情为 null 时,首次检测不删除,等待 1 分钟重试窗口
- 超过重试窗口仍为 null 才删除订单
- 订单详情正常或已部分卖出时清除缓存
- 避免 ResponseLoggingInterceptor 导致的响应体被消费问题

修改文件:
- OrderStatusUpdateService.kt

相关 issue: 订单 0xd245a852... 因响应体为 null 被误删
2026-01-14 14:55:05 +08:00
WrBug 64391503c2 fix: 修复最大仓位金额未生效和交易receipt解析问题
1. 修复 CopyTradingFilterService 中最大仓位金额(maxPositionValue)未生效的问题
   - 当只配置了最大仓位金额但未配置 maxSpread 或 minOrderDepth 时,
     仓位检查被跳过导致过滤失效
   - 将仓位检查移到 needOrderbook 判断之前,确保始终执行仓位检查

2. 修复 OnChainWsService 中交易 receipt 为 JsonNull 时的空指针问题
   - 添加 JsonNull 检查,防止解析失败时出现异常
2026-01-14 05:04:31 +08:00
WrBug 92b75d1926 refactor: 将 Polymarket API URL 配置改为代码常量
- 创建 PolymarketConstants 常量类,集中管理所有 Polymarket API URL
- 更新所有服务类,将配置注入改为使用常量:
  * ApiHealthCheckService: 移除配置注入,使用常量
  * RetrofitFactory: 移除 CLOB 和 Gamma API URL 配置注入
  * RelayClientService: 移除 Builder Relayer URL 配置注入
  * BlockchainService: 移除 Data API URL 配置注入
  * PolymarketApiKeyService: 移除 CLOB API URL 配置注入
  * RetrofitConfig: 移除 CLOB API URL 配置注入
  * OrderPushService: 移除 RTDS WebSocket URL 配置注入
  * PolymarketActivityWsService: 移除 Activity WebSocket URL 配置注入
  * CopyTradingWebSocketService: 移除 User WebSocket URL 配置注入
  * PolymarketWebSocketHandler: 移除 RTDS WebSocket URL 配置注入
  * UnifiedOnChainWsService: 添加连接状态查询方法
- 从 application.properties 移除相关配置项,添加说明注释
- 完善 API 健康检查,添加缺失的检测项:
  * Polymarket Activity WebSocket
  * 链上 WebSocket
- 更新相关文档说明
2026-01-13 16:07:19 +08:00
WrBug e072d0c894 feat: 添加 Activity WebSocket 消息超时检测和自动重连机制
- 添加 lastActivityTime 变量记录最后一次收到 activity 消息的时间
- 实现 startActivityTimeoutCheck() 方法,每30秒检查一次消息接收情况
- 如果超过30秒未收到 activity 消息,自动触发 WebSocket 重连
- 在订阅成功后自动启动超时检测任务
- 在 stop() 方法中正确清理检测任务资源
2026-01-13 16:04:20 +08:00
WrBug cc40493ec6 Merge pull request #21 from WrBug/dev 2026-01-13 12:55:21 +08:00
WrBug 692cbd9a80 Merge pull request #20 from WrBug/dev 2026-01-12 15:40:39 +08:00
WrBug 43de0104e2 Merge pull request #19 from WrBug/dev 2026-01-10 17:55:31 +08:00
WrBug 2ccab42894 Merge pull request #18 from WrBug/dev
Release v1.1.8: Polymarket Activity WebSocket 双重监听方案
2026-01-09 11:11:51 +08:00
WrBug 3008cbcb50 Merge pull request #17 from WrBug/release/v1.1.7
Release v1.1.7
2026-01-07 12:57:12 +08:00
WrBug 42a318b501 Merge pull request #15 from WrBug/dev
v1.1.6 Release: 优化跟单仓位检查逻辑与精度修正
2026-01-05 11:13:49 +08:00
WrBug ecb737ec67 Merge pull request #14 from WrBug/dev
Release v1.1.5: 功能优化与改进
2026-01-04 13:09:11 +08:00
WrBug 0f3baec6cb Merge branch 'pre_release' 2026-01-03 21:58:29 +08:00
WrBug 764d684846 Merge pull request #13 from WrBug/pre_release
Release v1.1.4: WebSocket 票据认证和钱包类型支持
2026-01-03 18:33:23 +08:00
149 changed files with 21366 additions and 3145 deletions
+39
View File
@@ -0,0 +1,39 @@
# Cursor Skills 目录结构
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/``references/``assets/`
## 目录结构
```
.cursor/skills/
├── frontend/ # 前端相关 skill 分组
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name
│ ├── SKILL.md # 必填,技能定义与指令
│ └── scripts/
│ ├── check-i18n-keys.ts
│ └── package.json
├── backend/ # 后端相关 skill
└── common/ # 通用 skill
```
- **SKILL.md**YAML frontmatter`name``description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
- **scripts/**Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
## 添加新 Skill
1. **确定分组**:在 `frontend/``backend/``common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
## 示例
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。
## 运行 check-i18n-keys
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts
npm install
npm run check-i18n
```
@@ -0,0 +1,36 @@
---
name: check-i18n-keys
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
---
# Check I18n Keys
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
## 使用时机
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
- 用户要求运行多语言检查或执行 check-i18n 时
- 在修改或新增前端文案后,需要确认三语言 key 一致时
## 指令
1. **运行检查脚本**(在项目仓库根目录下执行):
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
```
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
2. **脚本行为**
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts
- 提取代码中 `t('key')` / `t("key")` 的 key
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
3. **结果处理**
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
## 可选目录说明
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
@@ -0,0 +1,302 @@
#!/usr/bin/env node
/**
* 检查前端多语言 key 完整性
*
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts
*
* 支持的检查:
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface CheckResult {
usedKeys: Set<string>;
localeKeys: {
'zh-CN': Set<string>;
'zh-TW': Set<string>;
'en': Set<string>;
};
missingKeys: {
'zh-CN': string[];
'zh-TW': string[];
'en': string[];
};
inconsistentKeys: Array<{
key: string;
existsIn: string[];
missingIn: string[];
}>;
}
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
type Locale = typeof LOCALES[number];
// 颜色输出(如果支持)
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message: string, color: keyof typeof colors = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
/**
* 从代码文件中提取所有 t() 调用中的 key
*/
function extractKeysFromCode(frontendDir: string): Set<string> {
const keys = new Set<string>();
// 匹配 t('key') 或 t("key") 或 t(`key`)
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
cwd: frontendDir,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
absolute: true,
});
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf-8');
let match;
while ((match = tPattern.exec(content)) !== null) {
const key = match[1];
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`)
if (!key.includes('${') && !key.includes('${')) {
keys.add(key);
}
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
}
}
return keys;
}
/**
* 加载语言文件
*/
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
const filePath = path.join(localeDir, locale, 'common.json');
if (!fs.existsSync(filePath)) {
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
return {};
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
return {};
}
}
/**
* 展平嵌套对象为点分隔的 key 路径
*/
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// 递归处理嵌套对象
const nestedKeys = flattenKeys(value, fullKey);
nestedKeys.forEach(k => keys.add(k));
} else {
// 叶子节点
keys.add(fullKey);
}
}
return keys;
}
/**
* 检查 key 完整性
*/
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
const result: CheckResult = {
usedKeys,
localeKeys,
missingKeys: {
'zh-CN': [],
'zh-TW': [],
'en': [],
},
inconsistentKeys: [],
};
// 检查每个语言文件中缺失的 key
for (const locale of LOCALES) {
for (const key of usedKeys) {
if (!localeKeys[locale].has(key)) {
result.missingKeys[locale].push(key);
}
}
}
// 检查不一致的 key(某些语言有,某些没有)
const allLocaleKeys = new Set<string>();
LOCALES.forEach(locale => {
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
});
for (const key of allLocaleKeys) {
const existsIn: Locale[] = [];
const missingIn: Locale[] = [];
for (const locale of LOCALES) {
if (localeKeys[locale].has(key)) {
existsIn.push(locale);
} else {
missingIn.push(locale);
}
}
// 如果某些语言有,某些没有,则不一致
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
result.inconsistentKeys.push({
key,
existsIn,
missingIn,
});
}
}
return result;
}
/**
* 打印检查报告
*/
function printReport(result: CheckResult): void {
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
// 统计信息
log('📊 统计信息:', 'blue');
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
log('');
// 缺失的 key
let hasMissing = false;
for (const locale of LOCALES) {
if (result.missingKeys[locale].length > 0) {
hasMissing = true;
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
result.missingKeys[locale].forEach(key => {
log(` - ${key}`, 'red');
});
log('');
}
}
// 不一致的 key
if (result.inconsistentKeys.length > 0) {
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
log(` - ${key}`, 'yellow');
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
});
log('');
}
// 总结
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
if (hasErrors) {
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
} else {
log('✅ 检查通过:所有 key 都完整且一致', 'green');
}
}
/**
* 主函数
*/
function main(): void {
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
const scriptDir = __dirname;
const skillDir = path.resolve(scriptDir, '..');
const frontendSkillDir = path.resolve(skillDir, '..');
const repoRoot = path.resolve(frontendSkillDir, '../../..');
const frontendDir = path.join(repoRoot, 'frontend');
const localesDir = path.join(frontendDir, 'src', 'locales');
// 检查目录是否存在
if (!fs.existsSync(frontendDir)) {
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
process.exit(1);
}
if (!fs.existsSync(localesDir)) {
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
process.exit(1);
}
log('🔍 开始检查多语言 key...\n', 'cyan');
// 1. 提取代码中使用的 key
log('📝 扫描代码文件...', 'blue');
const usedKeys = extractKeysFromCode(frontendDir);
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
// 2. 加载语言文件
log('📚 加载语言文件...', 'blue');
const localeKeys: Record<Locale, Set<string>> = {
'zh-CN': new Set(),
'zh-TW': new Set(),
'en': new Set(),
};
for (const locale of LOCALES) {
const localeObj = loadLocaleFile(localesDir, locale);
localeKeys[locale] = flattenKeys(localeObj);
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
}
log('');
// 3. 检查
log('🔎 检查 key 完整性...', 'blue');
const result = checkKeys(usedKeys, localeKeys);
// 4. 输出报告
printReport(result);
// 5. 退出码
const hasErrors =
result.missingKeys['zh-CN'].length > 0 ||
result.missingKeys['zh-TW'].length > 0 ||
result.missingKeys['en'].length > 0 ||
result.inconsistentKeys.length > 0;
process.exit(hasErrors ? 1 : 0);
}
// 运行
main();
@@ -0,0 +1,680 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.2",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/minimatch": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
@@ -0,0 +1,16 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"type": "module",
"scripts": {
"check-i18n": "tsx check-i18n-keys.ts"
},
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"types": ["node"]
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
+2 -2
View File
@@ -14,11 +14,11 @@
.DS_Store
# 构建产物
backend/build/
# 注意:frontend/dist 和 backend/build/libs 在使用 BUILD_IN_DOCKER=false 时是必需的
# 所以不能忽略它们。在 BUILD_IN_DOCKER=true 时,它们会被 Docker 内部编译覆盖
backend/.gradle/
backend/out/
backend/bin/
frontend/dist/
frontend/node_modules/
frontend/.vite/
frontend/.cache/
+246 -21
View File
@@ -4,39 +4,104 @@ on:
release:
types:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
type: string
tag_name:
description: 'Git Tag 名称(留空则使用 version'
required: false
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: write # 需要写权限以上传 Assets
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
- name: Extract version from release
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0
TAG_NAME="${{ github.event.release.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
# 从不同事件源提取版本号
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:从 release tag 中提取
TAG_NAME="${{ github.event.release.tag_name }}"
IS_PRERELEASE="${{ github.event.release.prerelease }}"
else
# workflow_dispatch 事件:从输入参数中提取
TAG_NAME="${{ github.event.inputs.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME="${{ github.event.inputs.version }}"
fi
# 如果仍然为空,尝试从 git ref 中提取
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
# 不是 tag,尝试从分支名或 commit SHA 获取
TAG_NAME=${GITHUB_REF#refs/heads/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
fi
fi
fi
IS_PRERELEASE="false"
fi
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
exit 1
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
echo " 当前版本号: $TAG_NAME"
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
fi
VERSION=${TAG_NAME#v} # 移除 v 前缀
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
echo "Full tag: $TAG_NAME"
echo "IS_PRERELEASE=$IS_PRERELEASE" >> $GITHUB_OUTPUT
if [ "$IS_PRERELEASE" = "true" ]; then
echo "📋 这是 Pre-release: $TAG_NAME"
else
echo "📦 这是正式版本: $TAG_NAME"
fi
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -48,12 +113,15 @@ jobs:
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>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
@@ -79,19 +147,155 @@ jobs:
exit 0
fi
# ============ 编译前后端产物 ============
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Backend JAR
run: |
cd backend
chmod +x gradlew
./gradlew bootJar --no-daemon
echo "✅ 后端构建完成"
ls -lh build/libs/*.jar
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Build Frontend
env:
VERSION: ${{ steps.extract_version.outputs.VERSION }}
GIT_TAG: ${{ steps.extract_version.outputs.TAG }}
GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
run: |
cd frontend
npm ci
npm run build
echo "✅ 前端构建完成"
echo "📦 版本信息: VERSION=${{ steps.extract_version.outputs.VERSION }}, GIT_TAG=${{ steps.extract_version.outputs.TAG }}"
du -sh dist/
# ============ 打包更新包 ============
- name: Create Update Package
run: |
echo "📦 开始打包更新包..."
# 创建目录结构
mkdir -p update-package/backend
mkdir -p update-package/frontend
# 复制后端 JAR
cp backend/build/libs/*.jar update-package/backend/polyhermes.jar
echo "✅ 后端 JAR 已复制"
# 复制前端产物
cp -r frontend/dist/* update-package/frontend/
echo "✅ 前端文件已复制"
# 创建版本信息文件
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
else
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
fi
cat > update-package/version.json <<EOF
{
"version": "${{ steps.extract_version.outputs.VERSION }}",
"tag": "${{ steps.extract_version.outputs.TAG }}",
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"releaseNotes": ${RELEASE_NOTES}
}
EOF
echo "✅ 版本信息已创建"
# 打包成 tar.gz
cd update-package
tar -czf ../polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz .
cd ..
echo "✅ 打包完成: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
ls -lh polyhermes-*.tar.gz
- name: Calculate Checksum
id: checksum
run: |
FILE="polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
CHECKSUM=$(sha256sum "$FILE" | awk '{print $1}')
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT
echo "✅ SHA256: $CHECKSUM"
echo "$CHECKSUM $FILE" > checksums.txt
- name: Upload Update Package to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_content_type: application/gzip
- name: Upload Checksums to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./checksums.txt
asset_name: checksums.txt
asset_content_type: text/plain
- name: Upload Update Package as Artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
path: |
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
checksums.txt
retention-days: 30
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
if [ ! -d "frontend/dist" ]; then
echo "❌ 错误:frontend/dist 不存在"
exit 1
fi
if [ ! -d "backend/build/libs" ] || [ -z "$(ls -A backend/build/libs/*.jar 2>/dev/null)" ]; then
echo "❌ 错误:backend/build/libs/*.jar 不存在"
exit 1
fi
echo "✅ 构建产物已准备好"
ls -lh frontend/dist/ | head -5
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/build-push-action@v5
with:
context: .
@@ -101,15 +305,23 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
wrbug/polyhermes:latest
${{ steps.extract_version.outputs.IS_PRERELEASE == 'false' && 'wrbug/polyhermes:latest' || '' }}
build-args: |
BUILD_IN_DOCKER=false
VERSION=${{ steps.extract_version.outputs.VERSION }}
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -123,13 +335,26 @@ jobs:
# 获取构建信息
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 }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
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>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-103
View File
@@ -1,103 +0,0 @@
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
+4 -2
View File
@@ -17,14 +17,16 @@ backend/out/
backend/*.log
backend/gradle-app.setting
backend/.gradle
backend/gradle-wrapper.jar
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
# backend/gradle/wrapper/gradle-wrapper.jar
polyhub/
# Kotlin
*.kt.bak
*.class
# Java
*.jar
!backend/gradle/wrapper/gradle-wrapper.jar # Gradle Wrapper JAR 应该被提交
*.war
*.ear
*.class
+88 -31
View File
@@ -1,6 +1,12 @@
# 多阶段构建:前后端一体化部署
# 阶段1:构建前端
# 多阶段构建:前后端一体化部署(支持混合编译)
# 构建参数:控制是否在 Docker 内编译
# - BUILD_IN_DOCKER=true (默认): Docker 内部编译(本地开发)
# - BUILD_IN_DOCKER=false: 使用外部产物(GitHub Actions
ARG BUILD_IN_DOCKER=true
# ==================== 阶段1:构建前端 ====================
FROM node:18-alpine AS frontend-build
ARG BUILD_IN_DOCKER
WORKDIR /app/frontend
@@ -13,19 +19,37 @@ ARG GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
ENV VERSION=${VERSION}
ENV GIT_TAG=${GIT_TAG}
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
# 复制前端文件
# 复制前端文件(先复制 package.json 以利用 Docker 缓存)
COPY frontend/package*.json ./
RUN npm ci
# 条件:仅在 Docker 内部编译时安装依赖
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
npm ci; \
fi
# 复制所有前端源文件
COPY frontend/ ./
# 构建前端(使用相对路径,通过 Nginx 代理)
# 版本号会通过环境变量注入到构建产物中
RUN npm run build
# 条件:仅在 Docker 内部编译时执行构建
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译前端..."; \
npm run build; \
else \
echo "⏭️ 使用外部产物..."; \
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
echo " 请先执行: cd frontend && npm install && npm run build"; \
exit 1; \
else \
echo "✅ 找到外部构建的前端产物"; \
fi; \
fi
# 阶段2:构建后端
# ==================== 阶段2:构建后端 ====================
FROM gradle:8.5-jdk17 AS backend-build
ARG BUILD_IN_DOCKER
WORKDIR /app/backend
@@ -33,60 +57,93 @@ WORKDIR /app/backend
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
COPY backend/gradle ./gradle
# 下载依赖(利用 Docker 缓存)
RUN gradle dependencies --no-daemon || true
# 条件:仅在 Docker 内部编译时下载依赖
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
gradle dependencies --no-daemon || true; \
fi
# 复制源代码
COPY backend/src ./src
# 构建应用
RUN gradle bootJar --no-daemon
# 尝试复制外部构建的 JAR(如果存在)
# 注意:COPY 指令如果源不存在会失败
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
COPY backend/build ./build-external
# 阶段3:运行环境
# 处理外部构建的 JAR(如果存在)
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
mkdir -p build/libs; \
cp build-external/libs/*.jar build/libs/; \
rm -rf build-external; \
else \
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
rm -rf build-external; \
mkdir -p build/libs; \
fi
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译后端..."; \
gradle bootJar --no-daemon; \
else \
echo "⏭️ 使用外部产物"; \
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
echo " 请先执行: cd backend && ./gradlew bootJar"; \
exit 1; \
else \
echo "✅ 使用外部构建的后端产物"; \
fi; \
fi
# ==================== 阶段3:运行环境 ====================
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 安装 Nginx 和必要的工具(包含时区数据)
# 安装 Nginx、Python 和必要的工具
RUN apt-get update && \
apt-get install -y nginx curl tzdata && \
apt-get install -y nginx curl tzdata jq python3 python3-flask python3-requests && \
rm -rf /var/lib/apt/lists/* && \
rm -rf /etc/nginx/sites-enabled/default
# 从构建阶段复制文件
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
# 复制 Nginx 配置
COPY docker/nginx.conf /etc/nginx/nginx.conf
# 创建启动脚本
# 创建更新服务相关目录和脚本
RUN mkdir -p /app/updates /app/backups /var/log/polyhermes
COPY docker/update-service.py /app/update-service.py
COPY docker/start.sh /app/start.sh
RUN chmod +x /app/start.sh
# 创建非 root 用户(用于运行后端应用
# 记录初始版本(从构建参数
ARG VERSION=dev
ARG GIT_TAG=dev
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
# 创建非 root 用户
RUN useradd -m -u 1000 appuser
# 设置目录权限Nginx 以 root 运行,后端应用以 appuser 运行)
# 设置目录权限
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
chown -R appuser:appuser /app && \
chown -R root:root /usr/share/nginx/html && \
chown -R root:root /var/log/nginx && \
chown -R root:root /var/lib/nginx && \
chown -R root:root /var/cache/nginx && \
chown -R root:root /etc/nginx && \
chown -R root:root /var/run
# 保持 root 用户(Nginx 需要 root 权限绑定 80 端口)
# USER appuser
chown -R root:root /usr/share/nginx/html /var/log/nginx /var/lib/nginx /var/cache/nginx /etc/nginx /var/run
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost/api/health || exit 1
CMD curl -f http://localhost/api/system/health || exit 1
# 启动服务(同时启动 Nginx 和后端)
# 启动服务
ENTRYPOINT ["/app/start.sh"]
+25
View File
@@ -116,6 +116,7 @@
- **API 健康检查**:实时监控 Polymarket API 的健康状态
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
- **公告管理**:查看系统公告和更新信息
- **动态更新**:支持在线更新系统版本,无需重新部署容器
### 🚀 技术特性
@@ -164,6 +165,29 @@
**部署步骤**
#### ⚡ 一键安装(最快,推荐新用户)
**使用 curl(推荐):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**使用 wget**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
这个命令会自动:
- 📁 创建专用工作目录 `~/polyhermes`
- ✅ 自动检查 Docker 环境
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
- 🔐 自动生成安全的随机密钥
- 🚀 自动下载最新镜像并部署
---
#### 📋 其他部署方式
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
**方式 1:独立部署(无需 clone 代码,推荐)**
@@ -397,6 +421,7 @@ cd frontend
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
### 🤝 贡献指南
+23
View File
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
**Deployment Steps**:
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
**Using curl (Recommended):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**Using wget:**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
This command will automatically:
- 📁 Create dedicated working directory `~/polyhermes`
- ✅ Automatically check Docker environment
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
- 🔐 Automatically generate secure random keys
- 🚀 Automatically download latest images and deploy
---
#### 📋 Alternative Deployment Methods
1. **Using Docker Hub Images (Recommended, Production First Choice)**
**Method 1: Standalone Deployment (No code clone required, Recommended)**
-971
View File
@@ -1,971 +0,0 @@
# v1.1.10
## 🚀 主要功能
### 📢 推送已过滤订单功能
- **新增推送已过滤订单功能(pushFilteredOrders**,默认关闭
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
- 帮助用户了解哪些订单被过滤以及过滤原因
- **数据库迁移**
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates`
- 添加 `push_filtered_orders` 字段到 `copy_trading`
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
- **后端实现**
- 在实体类(`CopyTradingTemplate``CopyTrading`)中添加 `pushFilteredOrders` 字段
- 在 DTO 中添加字段支持(创建、更新、查询)
- 在 Service 中处理字段的创建、更新和传递
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
- **前端实现**
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
- 支持多语言(中文、繁体中文、英文)
### 🔍 优化订单列表筛选功能
- **支持按市场标题搜索**
- 买入订单列表:添加市场标题筛选,移除方向筛选
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
- 已成交订单列表:添加市场标题筛选和市场列显示
- **改进分组体验**
- 记录用户的分组偏好到 localStorage,跨会话持久化
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
- **后端优化**
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
### ⚡ 优化跟单关系统计性能
- **移除未实现盈亏计算**
- 移除未实现盈亏和持仓价值的计算逻辑
- 总盈亏现在仅包含已实现盈亏,计算更准确
- 简化盈亏百分比计算,仅基于已实现盈亏
- **性能提升**
- 从 1+N 次网络请求减少到 0 次
- 删除不再使用的方法和依赖注入
- 统计查询速度显著提升
### 🐳 Docker 容器时区配置
- **支持通过 .env 自定义时区**:
-`docker-compose.yml``docker-compose.prod.yml` 中添加时区环境变量配置
- 支持通过 `TZ` 环境变量自定义容器时区
- 默认使用系统时区
## 🐛 Bug 修复
### 修复跟单配置更新时清空可选字段无法保存的问题
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
- **修复**
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
- **实现方案**
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
### 修复新建和编辑页面的截止时间输入框交互问题
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
- **修复**
-`min``1` 改为 `0`,允许空值
- 优化 `onChange` 处理,当值为 `0``null``undefined` 时设置为 `undefined`(清空)
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
- 修改 `parser`,空值时返回空字符串而不是 `0`
### 修复订单通知重复发送和时间显示问题
- **修复并发导致的重复通知问题**:
-`OrderStatusUpdateService` 中实现双重检查机制
- 先保存订单标记为已发送,再重新查询数据库检查
- 防止定时任务并发时重复发送同一订单的通知
- 同时修复买入订单和卖出订单的通知逻辑
- **修复 Telegram 通知时间显示**
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
- 使用订单的 `createdAt` 时间戳作为通知显示时间
- 而不是使用当前通知发送时间
- 更准确反映订单的实际创建时间
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
- **修复**
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
## 📝 技术细节
### 数据库变更
- **迁移脚本**`V24__add_push_filtered_orders_to_templates.sql`
- **变更内容**
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **无新增接口**
- **无移除接口**
- **请求/响应变更**
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
### 前端变更
- **新增字段**
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
- **组件更新**
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 优化截止时间输入框交互逻辑
- **多语言支持**
- 添加中文、繁体中文、英文翻译
## 📊 变更统计
- **提交数量**6 个提交
- **文件变更**30 个文件
- **代码变更**+651 行 / -555 行(净增加 96 行)
### 详细文件变更
**后端变更**
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
**前端变更**
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
- `locales/*/common.json` - 添加多语言翻译(+31 行)
**配置文件变更**
- `docker-compose.yml` - 添加时区配置(+5 行)
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
- `Dockerfile` - 优化构建配置(+4 行)
## 🔄 主要提交
```
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
6af76c4 fix: 修复订单通知重复发送和时间显示问题
```
## 🎯 升级建议
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
- 现有数据不受影响,新字段默认值为 `false`
2. **配置更新**
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
- 无需其他配置变更
3. **兼容性**
- 完全向后兼容,不影响现有功能
- API 变更都是新增字段,不影响现有调用
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.10`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
---
**发布日期**2026-01-12
---
# v1.1.9
## 🐛 Bug 修复
### 修复盈亏统计计算错误问题
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
- **修复**
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
- 两个场景都使用 SQL 过滤:
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
- **优化效果**
- SQL 层面直接过滤,减少数据传输,提高查询效率
- 代码更简洁,逻辑更清晰
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
- 确保盈亏统计计算的准确性
## 📝 技术细节
- **Repository 变更**
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
- **Service 变更**
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
## 📊 变更统计
- **提交数量**1 个提交
- **文件变更**2 个文件
- **代码变更**+57 行 / -19 行(净增加 38 行)
### 详细文件变更
**后端变更**
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
## 🔄 主要提交
```
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
```
## 🎯 升级建议
1. **数据库迁移**:无需数据库迁移,可直接升级
2. **配置更新**:无需配置变更
3. **兼容性**:完全向后兼容,不影响现有功能
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.9`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
---
# v1.1.8
## 🚀 主要功能
### ⚡ Polymarket Activity WebSocket 双重监听方案
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
- 延迟 < 100ms,适合快速跟单场景
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写,提高匹配准确性
- **双重监听机制**
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
- 自动去重,避免重复处理同一笔交易
- **协议支持**
- 支持 `connection_id` 字段
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
- **参考文档**
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
- **数据库迁移**`V23__remove_unique_constraint_from_copy_trading.sql`
### 🎯 市场截止时间筛选功能
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
- **数据库字段**`max_market_end_date`(时间戳,单位:秒)
- **数据库迁移**`V22__add_max_market_end_date_to_copy_trading.sql`
### 🔍 关键字过滤功能
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
- **过滤模式**
- `DISABLED`:禁用关键字过滤
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
- **数据库字段**`keyword_filter_mode``keywords`JSON 数组)
- **数据库迁移**`V20__add_keyword_filter.sql`
### 📊 订单列表功能重构
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
- **市场信息展示**
- 显示市场标题和 slug
- 支持跳转到 Polymarket 市场页面
- 显示市场基本信息(标题、slug、截止时间等)
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
- **UI/UX 优化**
- 优化订单列表展示样式
- 改进移动端适配
### 📈 市场信息管理和缓存优化
- **新增 MarketService**:统一管理市场信息查询和缓存
- 使用 LRU 缓存提高查询性能
- 支持从 Gamma API 和 CLOB API 查询市场信息
- 自动缓存市场信息,减少 API 调用
- **新增 Market 实体和表**:持久化存储市场信息
- 存储市场标题、slug、eventSlug、endDate 等基本信息
- 支持通过 marketId 快速查询市场信息
- **数据库迁移**
- `V19__create_markets_table.sql`:创建市场信息表
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
## 🐛 Bug 修复
### 前端 TypeScript 类型错误修复
- **修复 InputNumber parser 类型错误**`parser` 函数应返回 `number` 而不是 `string`
- **修复文件**
- `src/pages/CopyTradingOrders/AddModal.tsx`
- `src/pages/CopyTradingOrders/EditModal.tsx`
## 📝 文档更新
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
- **更新开发规范**JSON 解析规范和 Data Class 规范
## 📊 统计信息
- **61 个文件被修改**
- **+5125 行新增代码**
- **-1942 行删除代码**
- **净增加 3183 行代码**
## 🔄 主要提交
```
d376a82 feat: 添加市场信息管理和订单ID复制功能
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
0327eaf feat: 添加市场截止时间筛选功能
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
```
## 🎯 升级建议
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.8`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
---
# v1.1.7
## 🚀 主要功能
### 💰 Polymarket Maker Rebates Program 费率支持
- **新增费率查询 API 接口** (`getFeeRate`)
- 支持动态查询 Maker Rebates Program 费率
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
- **动态费率获取**
- 在所有订单创建处动态获取费率:
* 跟单买入订单 (`processBuyTrade`)
* 跟单卖出订单 (`matchSellOrder`)
* 账户卖出订单 (`sellPosition`)
- 费率获取失败时降级到默认值 "0",确保系统可用性
- 添加详细的日志记录,便于监控和调试
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
### 🔧 Docker 部署优化
- **日志级别环境变量支持**
-`application.properties` 中支持通过 `LOG_LEVEL_ROOT``LOG_LEVEL_APP` 环境变量配置日志级别
-`docker-compose.yml``docker-compose.prod.yml` 中添加日志级别环境变量配置
-`deploy.sh``.env` 模板中添加日志级别配置说明
- 支持通过环境变量动态配置日志级别,无需修改配置文件
- 默认值:`root=INFO`, `app=DEBUG`
## 🐛 Bug 修复
### 修复市场条件查询的 RPC 调用错误
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted
- **修复**
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)` 函数调用
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
### 修复 RPC 错误时误创建自动卖出记录的问题
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
- **修复**
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
-`getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
- 避免在市场不存在或尚未创建时误判为已卖出
## 📝 文档更新
### 更新 Telegram 群链接
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
- 更新了以下文件:
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
- `RELEASE.md` - 相关链接
- `README.md``README_EN.md` - 相关链接部分
### 添加 Docker 版本徽章
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
- 版本信息自动更新,无需手动维护
## 📊 变更统计
- **提交数量**5 个提交
- **文件变更**16 个文件
- **代码变更**+205 行 / -886 行(净减少 681 行)
### 详细文件变更
**后端变更**
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
- `application.properties` - 添加日志级别环境变量支持(+6 行)
**前端变更**
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
**配置文件变更**
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
- `deploy.sh` - 添加日志级别配置说明(+5 行)
**文档变更**
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
## 🔧 技术细节
### API 变更
- **新增接口**
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
- **无移除接口**
### 环境变量变更
- **新增环境变量**
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
### 合约调用修复
- **修复的函数调用**
-`conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)`
- 使用正确的 ConditionalTokens 合约函数签名
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
## 📝 升级说明
### 数据库升级
- **无需数据库迁移**:本次更新不涉及数据库结构变更
### 配置更新
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
- `LOG_LEVEL_ROOT=INFO`(默认)
- `LOG_LEVEL_APP=DEBUG`(默认)
### Docker 部署
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
```bash
docker pull wrbug/polyhermes:latest
docker-compose -f docker-compose.prod.yml up -d
```
## 🔗 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **Twitter**@polyhermes
- **Telegram 群组**https://t.me/polyhermes
---
**发布日期**2026-01-07
---
# 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 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 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 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
+77
View File
@@ -0,0 +1,77 @@
## 版本信息
- **版本号**: v1.1.15
- **发布日期**: 2026-01-19
- **基础版本**: v1.1.14
## 改动摘要
本次版本优化了订单详情处理逻辑,提升了系统稳定性和开发体验。
## 主要改动
### 🐛 Bug 修复
#### 1. 优化订单详情为 null 时的处理逻辑
- **提交**: 7385eff
- **文件**: OrderStatusUpdateService.kt
- **问题**:
- 订单详情为 null 且已发送通知超过 60 秒时,订单被直接删除
- 导致已经正确处理并发送 TG 通知的订单被意外删除
- **修复**:
- 当订单详情为 null 且 notificationSent = true 超过 60 秒时,将订单状态改为 fully_matched
- fully_matched 状态的订单会被自动过滤,不再查询详情
- 避免已处理的订单被误删除
### 🧹 日志清理
#### 2. 清理 MarketPollingService 中多余的 debug 日志
- **提交**: d768da7, 07b4d65
- **文件**: MarketPollingService.kt
- **改进**:
- 删除多余的 debug 日志输出
- 减少冗余日志,提升日志可读性
- 优化性能(减少日志 I/O
### ✨ 新增功能
#### 3. 添加订单详情查询工具脚本
- **提交**: b658270
- **新增文件**:
- scripts/get-order-detail.js - 订单详情查询脚本
- scripts/package.json - 依赖配置文件
- **功能**:
- 快速查询 Polymarket 订单详情
- 支持自动创建 API Key
- 完善的错误处理和参数验证
- 详细的订单信息输出
## 文件变更统计
- **修改文件数**: 3
- **新增文件数**: 2
- **新增行数**: 192
- **删除行数**: 6
## 技术细节
### 订单状态管理优化
- 使用 fully_matched 状态标记已处理订单
- 通过数据库查询条件自动过滤,无需额外缓存
- 保持数据一致性和可追溯性
### 工具脚本特性
- 基于 Polymarket CLOB Client v5.2.1
- 支持 derive/create API Key 自动处理
- 完整的参数验证和错误提示
- 友好的命令行交互体验
## 升级建议
- 无需特殊操作,直接部署即可
- 建议验证订单处理逻辑是否正常工作
- 可以使用新增的工具脚本进行调试
## 完整提交列表
- 7385eff - 优化订单详情为null时的处理逻辑
- d768da7 - 清理 MarketPollingService 中多余的 debug 日志
- b658270 - 添加订单详情查询脚本
- 07b4d65 - 清理 MarketPollingService 调试日志
+1 -1
View File
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
- 代理配置错误
**排查步骤**:
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`
2. 检查网络连接
3. 查看详细错误日志
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# 清理 Flyway V29 失败记录的脚本
echo "=== 清理 Flyway V29 失败记录 ==="
echo ""
echo "请确保 MySQL 正在运行,然后输入数据库密码"
echo ""
# 数据库配置
DB_HOST="localhost"
DB_PORT="3306"
DB_NAME="polymarket_bot"
DB_USER="root"
# 检查 MySQL 命令是否可用
if ! command -v mysql &> /dev/null; then
echo "❌ 错误: 未找到 mysql 命令"
echo ""
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL"
echo ""
echo "-- 1. 查看 Flyway 历史记录"
echo "SELECT version, description, installed_on, success "
echo "FROM flyway_schema_history "
echo "WHERE version >= 28"
echo "ORDER BY installed_rank;"
echo ""
echo "-- 2. 删除 V29 的失败记录"
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
echo ""
exit 1
fi
# 执行清理
echo "正在连接数据库..."
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
-- 查看当前状态
SELECT '=== 当前 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
-- 删除 V29 失败记录
SELECT '=== 删除 V29 记录 ===' as '';
DELETE FROM flyway_schema_history WHERE version = '29';
-- 确认删除结果
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
-- 再次查看状态
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
EOF
echo ""
echo "✅ 清理完成!现在可以重启应用了"
Binary file not shown.
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取 Relay PayloadPROXY 类型执行时使用)
* GET /relay-payload?address={address}&type=PROXY
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
*/
@GET("/relay-payload")
suspend fun getRelayPayload(
@Query("address") address: String,
@Query("type") type: String
): Response<RelayPayload>
/**
* 获取交易状态
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
*/
data class SignatureParams(
@SerializedName("gasPrice")
@@ -114,7 +126,19 @@ interface BuilderRelayerApi {
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null
val refundReceiver: String? = null,
@SerializedName("relayerFee")
val relayerFee: String? = null,
@SerializedName("gasLimit")
val gasLimit: String? = null,
@SerializedName("relayHub")
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null
)
/**
@@ -142,6 +166,17 @@ interface BuilderRelayerApi {
@SerializedName("nonce")
val nonce: String
)
/**
* Relay PayloadPROXY 执行时获取 relay 地址与 nonce
* 参考: builder-relayer-client types RelayPayload
*/
data class RelayPayload(
@SerializedName("address")
val address: String,
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.util.createClient
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import retrofit2.Retrofit
@@ -23,9 +23,6 @@ class RetrofitConfig(
private val gson: Gson
) {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
/**
* 创建 CLOB API 客户端
* 用于跟单系统的订单操作和交易查询
@@ -38,7 +35,7 @@ class RetrofitConfig(
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -0,0 +1,48 @@
package com.wrbug.polymarketbot.constants
/**
* Polymarket API 常量
* 集中管理所有 Polymarket API 的 URL 配置
*/
object PolymarketConstants {
/**
* Polymarket CLOB API 基础 URL
*/
const val CLOB_BASE_URL = "https://clob.polymarket.com"
/**
* Polymarket RTDS WebSocket URL
* 用于订单推送服务
*/
const val RTDS_WS_URL = "wss://ws-subscriptions-clob.polymarket.com"
/**
* Polymarket User Channel WebSocket URL
* 用于跟单服务(订阅 Leader 交易)
*/
const val USER_WS_URL = "wss://ws-live-data.polymarket.com"
/**
* Polymarket Activity WebSocket URL
* 用于 Activity 全局交易流监听
*/
const val ACTIVITY_WS_URL = "wss://ws-live-data.polymarket.com"
/**
* Polymarket Data API 基础 URL
*/
const val DATA_API_BASE_URL = "https://data-api.polymarket.com"
/**
* Polymarket Gamma API 基础 URL
*/
const val GAMMA_BASE_URL = "https://gamma-api.polymarket.com"
/**
* Builder Relayer API URL
* 用于 Gasless 交易
*/
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
}
@@ -23,6 +23,50 @@ class AccountController(
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 检查代理地址选项(用于导入前选择代理类型)
*/
@PostMapping("/check-proxy-options")
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
return try {
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
}
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
}
val result = runBlocking { accountService.checkProxyOptions(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("检查代理地址选项失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
e.message,
messageSource
)
)
}
}
)
} catch (e: Exception) {
logger.error("检查代理地址选项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 通过私钥导入账户
*/
@@ -45,14 +89,17 @@ class AccountController(
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
)
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
}
}
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.controller.auth
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.UserRepository
import com.wrbug.polymarketbot.service.auth.AuthService
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import jakarta.servlet.http.HttpServletRequest
@@ -18,7 +19,8 @@ import org.springframework.web.bind.annotation.*
class AuthController(
private val authService: AuthService,
private val messageSource: MessageSource,
private val webSocketTicketService: WebSocketTicketService
private val webSocketTicketService: WebSocketTicketService,
private val userRepository: UserRepository
) {
private val logger = LoggerFactory.getLogger(AuthController::class.java)
@@ -184,5 +186,32 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
}
}
/**
* 验证当前用户权限
* 用于动态更新服务验证管理员权限
* 管理员权限判断:是否为默认账户(isDefault == true
*/
@GetMapping("/verify")
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
// 从请求属性中获取用户名(由 JWT 拦截器设置)
val username = httpRequest.getAttribute("username") as? String
if (username == null) {
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
}
// 检查是否为默认账户(管理员)
val user = userRepository.findByUsername(username)
if (user == null || !user.isDefault) {
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
}
ResponseEntity.ok(ApiResponse.success(Unit))
} catch (e: Exception) {
logger.error("验证权限异常: ${e.message}", e)
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", messageSource))
}
}
}
@@ -0,0 +1,256 @@
package com.wrbug.polymarketbot.controller.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.backtest.BacktestService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 回测管理控制器
*/
@RestController
@RequestMapping("/api/backtest")
class BacktestController(
private val backtestService: BacktestService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
/**
* 创建回测任务
*/
@PostMapping("/tasks")
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
val result = runBlocking {
backtestService.createBacktestTask(request)
}
result.fold(
onSuccess = { dto ->
logger.info("回测任务创建成功: taskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("创建回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务列表
*/
@PostMapping("/tasks/list")
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
return try {
val result = backtestService.getBacktestTaskList(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务列表成功: total=${response.total}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务列表失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务列表异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务详情
*/
@PostMapping("/tasks/detail")
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
return try {
val result = backtestService.getBacktestTaskDetail(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务详情成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务详情失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务详情异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测交易记录
*/
@PostMapping("/tasks/trades")
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
return try {
val result = backtestService.getBacktestTrades(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测交易记录失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测交易记录异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 删除回测任务
*/
@PostMapping("/tasks/delete")
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("删除回测任务: taskId=${request.id}")
val result = backtestService.deleteBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务删除成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
}
}
/**
* 停止回测任务
*/
@PostMapping("/tasks/stop")
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("停止回测任务: taskId=${request.id}")
val result = backtestService.stopBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务停止成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("停止回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("停止回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
}
}
/**
* 重试回测任务
*/
@PostMapping("/tasks/retry")
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("重试回测任务: taskId=${request.id}")
val result = backtestService.retryBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务重试成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重试回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("重试回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
}
}
/**
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
*/
@PostMapping("/tasks/rerun")
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
val result = backtestService.rerunBacktestTask(request)
result.fold(
onSuccess = { dto ->
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("按配置重新测试失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("按配置重新测试异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
}
}
}
@@ -140,7 +140,7 @@ class LeaderController(
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderDetail(request.leaderId)
result.fold(
onSuccess = { leader ->
@@ -159,6 +159,36 @@ class LeaderController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询被跟单者余额
*/
@PostMapping("/balance")
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderBalance(request.leaderId)
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询 Leader 余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询 Leader 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
@@ -11,6 +11,37 @@ data class AccountImportRequest(
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
* 检查代理地址选项请求
*/
data class CheckProxyOptionsRequest(
val walletAddress: String, // EOA 地址(必需)
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
)
/**
* 代理地址选项信息
*/
data class ProxyOptionDto(
val walletType: String, // "magic" 或 "safe"
val proxyAddress: String, // 代理地址
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description"
val availableBalance: String, // 可用余额
val positionBalance: String, // 仓位余额
val totalBalance: String, // 总余额
val positionCount: Int, // 持仓数量
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0
val error: String? = null // 获取失败时的错误信息(可选)
)
/**
* 检查代理地址选项响应
*/
data class CheckProxyOptionsResponse(
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
)
/**
* 账户更新请求
*/
@@ -93,6 +124,16 @@ data class AccountListResponse(
val total: Long
)
/**
* 钱包余额响应(通用类,用于 Account 和 Leader
*/
data class WalletBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 账户余额响应
*/
@@ -108,6 +149,7 @@ data class AccountBalanceResponse(
*/
data class PositionDto(
val marketId: String,
val title: String?, // 市场名称
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
@@ -0,0 +1,211 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 回测任务创建请求
*/
data class BacktestCreateRequest(
val taskName: String, // 回测任务名称
val leaderId: Long, // Leader ID
val initialBalance: String, // 初始资金
val backtestDays: Int, // 回测天数 (1-30)
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count
val copyMode: String? = null, // "RATIO" 或 "FIXED"
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val supportSell: Boolean? = null,
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
)
/**
* 回测任务列表请求
*/
data class BacktestListRequest(
val leaderId: Long? = null, // Leader ID(可选)
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val sortBy: String? = null, // profitAmount / profitRate / createdAt
val sortOrder: String? = null, // asc / desc
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测任务详情请求
*/
data class BacktestDetailRequest(
val id: Long // 回测任务ID
)
/**
* 回测交易记录请求
*/
data class BacktestTradeListRequest(
val taskId: Long, // 回测任务ID
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测进度查询请求
*/
data class BacktestProgressRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务停止请求
*/
data class BacktestStopRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务删除请求
*/
data class BacktestDeleteRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务重试请求
*/
data class BacktestRetryRequest(
val id: Long // 回测任务ID
)
/**
* 按当前配置重新测试请求(仅支持已完成任务)
*/
data class BacktestRerunRequest(
val id: Long, // 源回测任务ID
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
)
/**
* 回测任务列表响应
*/
data class BacktestListResponse(
val list: List<BacktestTaskDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测任务详情响应
*/
data class BacktestDetailResponse(
val task: BacktestTaskDto,
val config: BacktestConfigDto,
val statistics: BacktestStatisticsDto
)
/**
* 回测交易记录列表响应
*/
data class BacktestTradeListResponse(
val list: List<BacktestTradeDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测进度响应
*/
data class BacktestProgressResponse(
val progress: Int, // 执行进度 (0-100)
val currentBalance: String, // 当前余额
val totalTrades: Int, // 总交易笔数
val status: String // 任务状态
)
/**
* 回测任务 DTO
*/
data class BacktestTaskDto(
val id: Long,
val taskName: String,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String?,
val initialBalance: String,
val finalBalance: String?,
val profitAmount: String?,
val profitRate: String?,
val backtestDays: Int,
val startTime: Long,
val endTime: Long?,
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val progress: Int,
val totalTrades: Int,
val createdAt: Long,
val executionStartedAt: Long?,
val executionFinishedAt: Long?
)
/**
* 回测配置 DTO
*/
data class BacktestConfigDto(
val copyMode: String,
val copyRatio: String,
val fixedAmount: String?,
val maxOrderSize: String,
val minOrderSize: String,
val maxDailyLoss: String,
val maxDailyOrders: Int,
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?,
val maxPositionValue: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
)
/**
* 回测统计信息 DTO
*/
data class BacktestStatisticsDto(
val totalTrades: Int, // 总交易笔数
val buyTrades: Int, // 买入笔数
val sellTrades: Int, // 卖出笔数
val winTrades: Int, // 盈利交易笔数
val lossTrades: Int, // 亏损交易笔数
val winRate: String, // 胜率(%)
val maxProfit: String, // 最大单笔盈利
val maxLoss: String, // 最大单笔亏损
val maxDrawdown: String, // 最大回撤
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
)
/**
* 回测交易记录 DTO
*/
data class BacktestTradeDto(
val id: Long,
val tradeTime: Long,
val marketId: String,
val marketTitle: String?,
val side: String, // BUY/SELL/SETTLEMENT
val outcome: String,
val outcomeIndex: Int?,
val quantity: String,
val price: String,
val amount: String,
val fee: String,
val profitLoss: String?,
val balanceAfter: String,
val leaderTradeId: String?
)
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -151,7 +149,6 @@ data class CopyTradingDto(
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -36,6 +36,13 @@ data class LeaderListRequest(
val category: String? = null // sports 或 crypto
)
/**
* Leader 余额请求
*/
data class LeaderBalanceRequest(
val leaderId: Long // LeaderID(必需)
)
/**
* Leader 信息响应
*/
@@ -47,6 +54,7 @@ data class LeaderDto(
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val backtestCount: Long = 0, // 回测数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
val createdAt: Long,
@@ -61,3 +69,16 @@ data class LeaderListResponse(
val total: Long
)
/**
* Leader 余额响应
*/
data class LeaderBalanceResponse(
val leaderId: Long,
val leaderAddress: String,
val leaderName: String?,
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 用户交易数据
* 用于回测功能,从 Polymarket API 获取的用户交易历史
*/
data class TradeData(
val tradeId: String, // 交易 ID
val marketId: String, // 市场 ID
val marketTitle: String?, // 市场标题
val marketSlug: String?, // 市场 Slug
val side: String, // 交易方向: BUY/SELL
val outcome: String, // 结果: YES/NO 或 outcomeIndex
val outcomeIndex: Int?, // 结果索引
val price: BigDecimal, // 成交价格
val size: BigDecimal, // 成交数量
val amount: BigDecimal, // 成交金额
val timestamp: Long // 交易时间戳
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TradeData) return false
return tradeId == other.tradeId
}
override fun hashCode(): Int {
return tradeId.hashCode()
}
}
@@ -16,11 +16,11 @@ data class Account(
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(AES 加密存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@Column(name = "wallet_address", nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
@Column(name = "proxy_address", nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
@Column(name = "api_key", length = 500)
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
@@ -0,0 +1,151 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 回测任务实体
*/
@Entity
@Table(name = "backtest_task")
data class BacktestTask(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "task_name", nullable = false, length = 100)
val taskName: String,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
// 回测参数
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
val initialBalance: BigDecimal,
@Column(name = "final_balance", precision = 20, scale = 8)
var finalBalance: BigDecimal? = null,
@Column(name = "profit_amount", precision = 20, scale = 8)
var profitAmount: BigDecimal? = null,
@Column(name = "profit_rate", precision = 10, scale = 4)
var profitRate: BigDecimal? = null, // 收益率(%)
@Column(name = "backtest_days", nullable = false)
val backtestDays: Int,
@Column(name = "start_time", nullable = false)
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
@Column(name = "end_time")
var endTime: Long? = null, // 回测结束时间(历史时间)
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE,
@Column(name = "fixed_amount", precision = 20, scale = 8)
val fixedAmount: BigDecimal? = null,
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
@Column(name = "max_daily_orders", nullable = false)
val maxDailyOrders: Int = 100,
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true,
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null,
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: 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 = "avg_holding_time")
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
@Column(name = "data_source", length = 50)
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
// 执行状态
@Column(name = "status", nullable = false, length = 20)
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
@Column(name = "progress", nullable = false)
var progress: Int = 0, // 执行进度(0-100)
@Column(name = "total_trades", nullable = false)
var totalTrades: Int = 0,
@Column(name = "buy_trades", nullable = false)
var buyTrades: Int = 0,
@Column(name = "sell_trades", nullable = false)
var sellTrades: Int = 0,
@Column(name = "win_trades", nullable = false)
var winTrades: Int = 0,
@Column(name = "loss_trades", nullable = false)
var lossTrades: Int = 0,
@Column(name = "win_rate", precision = 5, scale = 2)
var winRate: BigDecimal? = null, // 胜率(%)
@Column(name = "max_profit", precision = 20, scale = 8)
var maxProfit: BigDecimal? = null, // 最大单笔盈利
@Column(name = "max_loss", precision = 20, scale = 8)
var maxLoss: BigDecimal? = null, // 最大单笔亏损
@Column(name = "max_drawdown", precision = 20, scale = 8)
var maxDrawdown: BigDecimal? = null, // 最大回撤
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
// 时间字段
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "execution_started_at")
var executionStartedAt: Long? = null,
@Column(name = "execution_finished_at")
var executionFinishedAt: Long? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis(),
@Column(name = "last_processed_trade_time")
var lastProcessedTradeTime: Long? = null,
@Column(name = "last_processed_trade_index")
var lastProcessedTradeIndex: Int? = null,
@Column(name = "processed_trade_count")
var processedTradeCount: Int = 0
)
@@ -0,0 +1,62 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 回测交易记录实体
* 用于记录回测过程中的每笔模拟交易
*/
@Entity
@Table(name = "backtest_trade")
data class BacktestTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "backtest_task_id", nullable = false)
val backtestTaskId: Long,
@Column(name = "trade_time", nullable = false)
val tradeTime: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
@Column(name = "side", nullable = false, length = 20)
val side: String, // BUY/SELL/SETTLEMENT
@Column(name = "outcome", nullable = false, length = 50)
val outcome: String, // YES/NO 或 outcomeIndex
@Column(name = "outcome_index")
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "price", nullable = false, precision = 20, scale = 8)
val price: BigDecimal,
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal,
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0
@Column(name = "profit_loss", precision = 20, scale = 8)
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
val balanceAfter: BigDecimal, // 交易后余额
@Column(name = "leader_trade_id", length = 100)
val leaderTradeId: String? = null, // Leader 原始交易ID
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
@Column(name = "notification_sent", nullable = false)
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
@Column(name = "source", nullable = false, length = 20)
val source: String, // 订单来源:activity-wsPolymarket WebSocket)、onchain-wsOnChain WebSocket
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -83,9 +83,6 @@ data class CopyTrading(
@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 = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
@@ -231,7 +231,26 @@ enum class ErrorCode(
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
// 回测服务错误 (4601-4699)
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
companion object {
/**
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.enums
/**
* 钱包类型枚举
*/
enum class WalletType(val value: String, val description: String) {
/**
* Magic 钱包(邮箱/OAuth 登录)
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
*/
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
/**
* Safe 钱包(MetaMask 等 Web3 钱包)
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
*/
SAFE("safe", "SafeWeb3钱包)");
companion object {
/**
* 从字符串值解析钱包类型(不区分大小写)
*/
fun fromString(value: String?): WalletType {
if (value.isNullOrBlank()) {
return SAFE // 默认返回 SAFE
}
return values().find { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的钱包类型: $value")
}
/**
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
*/
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
if (value.isNullOrBlank()) {
return default
}
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
}
/**
* 检查字符串是否为有效的钱包类型
*/
fun isValid(value: String?): Boolean {
if (value.isNullOrBlank()) {
return false
}
return values().any { it.value.equals(value, ignoreCase = true) }
}
}
}
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
/**
* 检查代理地址是否存在
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTask
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测任务Repository
*/
@Repository
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
/**
* 根据 Leader ID 查询回测任务
*/
fun findByLeaderId(leaderId: Long): List<BacktestTask>
/**
* 根据状态查询回测任务
*/
fun findByStatus(status: String): List<BacktestTask>
/**
* 根据 Leader ID 和状态查询回测任务
*/
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据 Leader ID、收益率排序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据状态和创建时间倒序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
/**
* 更新回测任务状态
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务状态和错误信息
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务进度
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
}
@@ -0,0 +1,38 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测交易记录Repository
*/
@Repository
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
/**
* 根据回测任务ID查询所有交易记录
*/
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
/**
* 根据回测任务ID分页查询交易记录
*/
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
fun findByBacktestTaskId(
backtestTaskId: Long,
pageable: org.springframework.data.domain.Pageable
): org.springframework.data.domain.Page<BacktestTrade>
/**
* 根据回测任务ID统计交易数量
*/
fun countByBacktestTaskId(backtestTaskId: Long): Long
/**
* 删除回测任务的所有交易记录(由级联删除处理)
*/
fun deleteByBacktestTaskId(backtestTaskId: Long)
}
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
fun countActivePositions(copyTradingId: Long): Int
/**
* 检查指定市场是否存在活跃仓位
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
* 按市场+方向(outcomeIndex)分别统计
*/
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
/**
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
*/
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
/**
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
@@ -66,11 +68,6 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
@@ -104,7 +101,8 @@ class AccountService(
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -120,25 +118,31 @@ class AccountService(
}
}
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
if (accountRepository.existsByProxyAddress(proxyAddress)) {
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
}
// 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用钱包地址后位)
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
val accountName = if (request.accountName.isNullOrBlank()) {
val walletAddress = request.walletAddress.trim()
// 取地址后四位(去掉 0x 前缀后取后四位)
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
walletAddress.substring(2)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val typeLabel = walletTypeEnum.name.uppercase()
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
proxyAddress.substring(2)
} else {
walletAddress
proxyAddress
}
if (addressWithoutPrefix.length >= 4) {
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
val suffix = if (proxyWithoutPrefix.length >= 4) {
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
} else {
addressWithoutPrefix.uppercase()
proxyWithoutPrefix.uppercase()
}
"$typeLabel-$suffix"
} else {
request.accountName.trim()
}
@@ -171,6 +175,192 @@ class AccountService(
}
}
/**
* 检查代理地址选项(用于账户导入前选择代理类型)
* 私钥导入:返回 Magic 和 Safe 两个选项
* 助记词导入:仅返回 Safe 选项
*/
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 验证至少提供了私钥或助记词之一
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
}
val options = mutableListOf<ProxyOptionDto>()
// 3. 判断导入类型
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
if (isPrivateKeyImport) {
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
coroutineScope {
val magicDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.MAGIC.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "magic",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Magic 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "magic",
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val safeDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.SAFE.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val magicOption = magicDeferred.await()
val safeOption = safeDeferred.await()
// Safe 在前,Magic 在后
options.add(safeOption)
options.add(magicOption)
}
} else {
// 助记词导入:仅获取 Safe 代理地址及资产
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
)
} else {
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
)
}
}
Result.success(CheckProxyOptionsResponse(options = options))
} catch (e: Exception) {
logger.error("检查代理地址选项失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -278,7 +468,7 @@ class AccountService(
if (accountId == null) {
return Result.failure(IllegalArgumentException("账户ID不能为空"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
@@ -288,68 +478,19 @@ class AccountService(
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 查询 USDC 余额和持仓信息
// 使用通用方法查询余额
val balanceResult = runBlocking {
try {
// 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 使用 /value 接口获取仓位总价值(而不是累加)
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
blockchainService.getWalletBalance(account.proxyAddress)
}
Result.success(balanceResult)
balanceResult.map { walletBalance: WalletBalanceResponse ->
AccountBalanceResponse(
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
@@ -876,7 +1017,7 @@ class AccountService(
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -885,7 +1026,7 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = 2, // Browser Wallet(与正确订单数据一致)
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
@@ -1238,13 +1379,6 @@ class AccountService(
*/
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
)
}
if (request.positions.isEmpty()) {
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
}
@@ -1266,7 +1400,17 @@ class AccountService(
accounts[accountId] = account
}
// 4. 验证并收集要赎回的仓位信息(按账户分组
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错
val hasMagicAccount = accounts.values.any {
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
}
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
)
}
// 5. 验证并收集要赎回的仓位信息(按账户分组)
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
val accountRedeemedInfo =
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
@@ -1309,7 +1453,7 @@ class AccountService(
accountRedeemedInfo[accountId] = accountInfo
}
// 5. 对每个账户执行赎回
// 6. 对每个账户执行赎回Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
var totalRedeemedValue = BigDecimal.ZERO
@@ -1329,11 +1473,13 @@ class AccountService(
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用区块链服务赎回仓位
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets
indexSets = indexSets,
walletType = walletTypeEnum
)
redeemResult.fold(
@@ -1364,7 +1510,7 @@ class AccountService(
)
}
// 6. 发送赎回推送通知(异步,不阻塞)
// 7. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -430,27 +430,20 @@ class PositionCheckService(
processedRedeemablePositions[positionKey] = now
}
// 赎回成功后,再查找订单并更新订单状态
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 shareleader2 对应 16 share
for (position in positionsToRedeem) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
if (position.outcomeIndex == null) {
continue
}
// 如果有未卖出订单,更新订单状态
if (unmatchedOrders.isNotEmpty()) {
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
if (copyTradingId != null) {
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
for (copyTrading in copyTradings) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
if (orders.isNotEmpty()) {
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
}
}
}
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
class PositionPollingService(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
@Value("\${position.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
// 订阅者列表(支持多个订阅者)
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
// 最新仓位数据(用于丢弃机制)
@Volatile
private var latestPositions: PositionListResponse? = null
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 事件分发协程(使用专门的线程,避免阻塞轮训)
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮训)
*/
@@ -52,7 +52,7 @@ class PositionPollingService(
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
startPolling()
}
/**
* 清理资源
*/
@@ -66,7 +66,7 @@ class PositionPollingService(
scope.cancel()
eventDispatcherScope.cancel()
}
/**
* 订阅仓位事件
* @param callback 回调函数,接收最新的仓位数据
@@ -78,7 +78,7 @@ class PositionPollingService(
latestPositions?.let { callback(it) }
}
}
/**
* 取消订阅仓位事件
*/
@@ -87,7 +87,7 @@ class PositionPollingService(
subscribers.remove(callback)
}
}
/**
* 启动轮训任务
*/
@@ -95,7 +95,7 @@ class PositionPollingService(
synchronized(lock) {
// 如果已经有轮训任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮训任务
pollingJob = scope.launch {
while (isActive) {
@@ -109,7 +109,7 @@ class PositionPollingService(
}
}
}
/**
* 轮训仓位数据并发布事件
* 使用专门的线程分发事件,避免阻塞轮训
@@ -123,7 +123,7 @@ class PositionPollingService(
if (positions != null) {
// 更新最新数据(丢弃旧数据,只保留最新的)
latestPositions = positions
// 在专门的线程中分发事件,避免阻塞轮训
eventDispatcherScope.launch {
try {
@@ -131,7 +131,7 @@ class PositionPollingService(
val currentSubscribers = synchronized(lock) {
subscribers.toList() // 复制列表,避免并发修改
}
currentSubscribers.forEach { callback ->
try {
callback(positions)
@@ -139,8 +139,6 @@ class PositionPollingService(
logger.error("通知订阅者失败: ${e.message}", e)
}
}
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
} catch (e: Exception) {
logger.error("分发仓位数据事件失败: ${e.message}", e)
}
@@ -0,0 +1,137 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 基于 start 游标的一批历史交易结果
* @param trades 本批交易列表(已按时间升序)
* @param nextCursorSeconds 下一页游标(API 的 start 参数,秒级);若本批不足 limit 条则为 null 表示最后一页
*/
data class LeaderTradesBatchResult(
val trades: List<TradeData>,
val nextCursorSeconds: Long?
)
/**
* 回测数据服务
* 直接从 Polymarket Data API 获取 Leader 历史交易,使用 start 游标分页(避免 offset 过大报错)
*/
@Service
class BacktestDataService(
private val leaderRepository: LeaderRepository,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
/**
* 按 start 游标获取一批 Leader 历史交易
* 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页
*
* @param leaderId Leader ID
* @param startTime 回测开始时间(毫秒)
* @param endTime 回测结束时间(毫秒)
* @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000
* @param limit 每批条数,建议 500
* @return 本批交易与下一页游标(null 表示没有下一页)
*/
suspend fun getLeaderHistoricalTradesBatch(
leaderId: Long,
startTime: Long,
endTime: Long,
cursorStartSeconds: Long,
limit: Int
): LeaderTradesBatchResult {
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
val dataApi = retrofitFactory.createDataApi()
val endSeconds = endTime / 1000
val maxRetries = 5
val retryDelay = 1000L
var lastException: Exception? = null
for (attempt in 1..maxRetries) {
try {
val response = dataApi.getUserActivity(
user = leader.leaderAddress,
type = listOf("TRADE"),
start = cursorStartSeconds,
end = endSeconds,
limit = limit,
offset = null,
sortBy = "TIMESTAMP",
sortDirection = "ASC"
)
if (!response.isSuccessful || response.body() == null) {
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
}
val activities = response.body()!!
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
val trades = activities.mapNotNull { activity ->
try {
if (activity.type != "TRADE") return@mapNotNull null
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
return@mapNotNull null
}
val tradeTimestamp = activity.timestamp * 1000
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
return@mapNotNull null
}
TradeData(
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
marketId = activity.conditionId,
marketTitle = activity.title,
marketSlug = activity.slug,
side = activity.side.uppercase(),
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
outcomeIndex = activity.outcomeIndex,
price = activity.price.toSafeBigDecimal(),
size = activity.size.toSafeBigDecimal(),
amount = activity.usdcSize.toSafeBigDecimal(),
timestamp = tradeTimestamp
)
} catch (e: Exception) {
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
null
}
}
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
val nextCursorSeconds: Long? = if (trades.size < limit) {
null
} else {
val maxTs = trades.maxOf { it.timestamp }
maxTs / 1000
}
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
} catch (e: Exception) {
lastException = e
logger.warn("$attempt/$maxRetries 次获取批次失败: ${e.message}")
if (attempt < maxRetries) {
logger.info("等待 $retryDelay 毫秒后重试...")
delay(retryDelay)
}
}
}
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
logger.error(errorMsg, lastException)
throw Exception(errorMsg, lastException)
}
}
@@ -0,0 +1,792 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
@Service
class BacktestExecutionService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val backtestDataService: BacktestDataService,
private val marketPriceService: MarketPriceService,
private val marketService: MarketService,
private val copyTradingFilterService: CopyTradingFilterService
) {
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
/**
* 持仓数据结构
* @param marketEndDate 市场结束时间(毫秒),用于到期结算判断,null 表示未知
*/
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int?,
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal?,
val marketEndDate: Long? = null
)
/**
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
*/
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
return CopyTrading(
id = task.id,
accountId = 0L,
leaderId = task.leaderId,
enabled = true,
copyMode = task.copyMode,
copyRatio = task.copyRatio,
fixedAmount = null,
maxOrderSize = task.maxOrderSize,
minOrderSize = task.minOrderSize,
maxDailyLoss = task.maxDailyLoss,
maxDailyOrders = task.maxDailyOrders,
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
delaySeconds = 0, // 回测按时间线执行,无需延迟
pollIntervalSeconds = 5,
useWebSocket = false,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = task.supportSell,
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
maxPositionValue = task.maxPositionValue,
minPrice = task.minPrice, // 最低价格
maxPrice = task.maxPrice, // 最高价格
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
pushFailedOrders = false,
pushFilteredOrders = false,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
/**
* 执行回测任务(支持分页和恢复)
* 自动处理所有页面的数据,支持中断恢复
*/
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
private val backtestBatchLimit = 500
@Transactional
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
try {
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
// 1. 更新任务状态为 RUNNING
task.status = "RUNNING"
task.executionStartedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
// 2. 初始化
var currentBalance = task.initialBalance
val positions = mutableMapOf<String, Position>()
val trades = mutableListOf<BacktestTrade>()
val dailyOrderCountCache = mutableMapOf<String, Int>()
val dailyLossCache = mutableMapOf<String, BigDecimal>()
val seenTradeIds = mutableSetOf<String>()
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
val endTime = System.currentTimeMillis()
val startTime = if (task.lastProcessedTradeTime == null) {
endTime - (task.backtestDays * 24L * 3600 * 1000)
} else {
task.startTime
}
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
"初始余额: ${task.initialBalance.toPlainString()}")
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
task.lastProcessedTradeTime!! / 1000
} else {
startTime / 1000
}
val endSeconds = endTime / 1000
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms")
var terminateBacktest = false
while (true) {
if (terminateBacktest) {
logger.info("余额已为负或不足,终止回测循环")
break
}
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
break
}
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
val currentPageTrades = mutableListOf<BacktestTrade>()
try {
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
task.leaderId,
startTime,
endTime,
cursorSeconds,
backtestBatchLimit
)
val pageTrades = batch.trades
if (pageTrades.isEmpty()) {
logger.info("本批无数据,所有数据处理完成")
break
}
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
val countAtBatchStart = task.processedTradeCount
var lastProcessedIndexInPage: Int? = null
var processedInBatch = 0
for (localIndex in pageTrades.indices) {
val leaderTrade = pageTrades[localIndex]
if (leaderTrade.tradeId in seenTradeIds) {
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
continue
}
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
continue
}
seenTradeIds.add(leaderTrade.tradeId)
val index = countAtBatchStart + processedInBatch
lastProcessedIndexInPage = index
processedInBatch++
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
val timeRange = endTime - startTime
val progress = if (timeRange > 0) {
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
min(99, ((elapsed * 100) / timeRange).toInt())
} else {
0
}
if (progress > task.progress) {
task.progress = progress
task.processedTradeCount = index + 1
backtestTaskRepository.save(task)
}
try {
// 5.1 实时检查并结算已到期的市场
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
// 5.2 检查余额和持仓状态
if (currentBalance <= BigDecimal.ONE) {
logger.info(
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
else "余额<=1,停止回测: $currentBalance"
)
terminateBacktest = true
break
}
// 5.3 应用过滤规则
val copyTrading = taskToCopyTrading(task)
val filterResult = copyTradingFilterService.checkFilters(
copyTrading,
tokenId = "",
tradePrice = leaderTrade.price,
copyOrderAmount = null,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
marketEndDate = null,
outcomeIndex = leaderTrade.outcomeIndex
)
if (!filterResult.isPassed) {
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
continue
}
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
val tradeDate = formatDate(leaderTrade.timestamp)
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
if (dailyOrderCount >= task.maxDailyOrders) {
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
continue
}
// 5.6 计算跟单金额
val followAmount = calculateFollowAmount(task, leaderTrade)
// 5.6.1 检查订单大小限制
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (followAmount < task.minOrderSize) {
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
followAmount
}
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
if (dailyLoss > task.maxDailyLoss) {
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
continue
}
// 5.7 处理买卖逻辑
if (leaderTrade.side == "BUY") {
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
currentBalance
} else {
finalFollowAmount
}
if (actualBuyAmount < task.minOrderSize) {
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
continue
}
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
if (quantity <= BigDecimal.ZERO) {
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
continue
}
val totalCost = actualBuyAmount
// 5.6.3 检查最大仓位限制(如果配置了)
if (task.maxPositionValue != null) {
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val currentPosition = positions[positionKey]
val currentPositionValue = if (currentPosition != null) {
currentPosition.quantity.multiply(currentPosition.avgPrice)
} else {
BigDecimal.ZERO
}
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
continue
}
}
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val price = leaderTrade.price.toSafeBigDecimal()
val leaderSize = leaderTrade.size.toSafeBigDecimal()
val existing = positions[positionKey]
positions[positionKey] = if (existing != null) {
val newQuantity = existing.quantity.add(quantity)
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
} else {
price
}
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = newQuantity,
avgPrice = newAvgPrice,
leaderBuyQuantity = newLeaderBuyQuantity,
marketEndDate = existing.marketEndDate
)
} else {
val market = marketService.getMarket(leaderTrade.marketId)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = price,
leaderBuyQuantity = leaderSize,
marketEndDate = market?.endDate
)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "BUY",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = actualBuyAmount,
fee = BigDecimal.ZERO,
profitLoss = null,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// 更新每日订单数缓存
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
} else {
// SELL 逻辑
if (!task.supportSell) {
continue
}
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
// 计算卖出数量
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity.multiply(
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
)
} else {
position.quantity
}
} else {
position.quantity
}
val actualSellQuantity = if (sellQuantity > position.quantity) {
position.quantity
} else {
sellQuantity
}
// 计算卖出金额
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
// 5.6.2 检查卖出金额限制
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (sellAmount < task.minOrderSize) {
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
sellAmount
}
val netAmount = finalSellAmount
// 计算盈亏
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "SELL",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = actualSellQuantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = finalSellAmount,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// SELL 订单不计入每日订单数限制
// 更新每日亏损缓存(只累加亏损,不累加盈利)
if (profitLoss < BigDecimal.ZERO) {
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
}
}
} catch (e: Exception) {
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
}
}
// 保存本批交易
if (currentPageTrades.isNotEmpty()) {
logger.info("保存本批交易,共 ${currentPageTrades.size}")
backtestTradeRepository.saveAll(currentPageTrades)
val lastTradeInPage = currentPageTrades.lastOrNull()
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
task.lastProcessedTradeIndex = lastProcessedIndexInPage
task.processedTradeCount = lastProcessedIndexInPage + 1
task.finalBalance = currentBalance
backtestTaskRepository.save(task)
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
}
} else {
logger.info("本批没有交易需要保存")
}
trades.addAll(currentPageTrades)
if (batch.nextCursorSeconds == null) {
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
break
}
cursorSeconds = batch.nextCursorSeconds!!
} catch (e: Exception) {
logger.error("获取或处理本批数据失败: ${e.message}", e)
// 重试失败,标记任务为 FAILED
throw e
}
}
// 6. 处理回测结束时仍未到期的持仓
val remainingSettlements = mutableListOf<BacktestTrade>()
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
if (remainingSettlements.isNotEmpty()) {
backtestTradeRepository.saveAll(remainingSettlements)
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
}
// 7. 计算最终统计数据
val statistics = calculateStatistics(trades)
// 8. 更新任务状态
val profitAmount = currentBalance.subtract(task.initialBalance)
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
} else {
BigDecimal.ZERO
}
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
task.finalBalance = currentBalance
task.profitAmount = profitAmount
task.profitRate = profitRate
task.endTime = endTime
task.status = finalStatus
task.progress = 100
task.totalTrades = trades.size
task.buyTrades = trades.count { it.side == "BUY" }
task.sellTrades = trades.count { it.side == "SELL" }
task.winTrades = statistics.winTrades
task.lossTrades = statistics.lossTrades
task.winRate = statistics.winRate.toSafeBigDecimal()
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
task.avgHoldingTime = statistics.avgHoldingTime
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
logger.info("回测任务执行完成: taskId=${task.id}, " +
"最终余额=${currentBalance.toPlainString()}, " +
"收益额=${task.profitAmount?.toPlainString()}, " +
"收益率=${task.profitRate?.toPlainString()}%, " +
"总交易数=${trades.size}, " +
"盈利率=${task.winRate?.toPlainString()}%")
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${task.id}", e)
task.status = "FAILED"
task.errorMessage = e.message
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
throw e
}
}
/**
* 结算已到期的市场
* @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库
*/
private suspend fun settleExpiredPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
batchTradesToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
try {
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
continue
}
// 获取市场当前价格
val marketPrice = marketPriceService.getCurrentMarketPrice(
position.marketId,
position.outcomeIndex ?: 0
)
val price = marketPrice.toSafeBigDecimal()
// 通过市场价格判断结算价格
val settlementPrice = when {
price >= BigDecimal("0.95") -> BigDecimal.ONE
price <= BigDecimal("0.05") -> BigDecimal.ZERO
else -> position.avgPrice
}
val settlementValue = position.quantity.multiply(settlementPrice)
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val settlementTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = when {
settlementPrice == BigDecimal.ONE -> "WIN"
settlementPrice == BigDecimal.ZERO -> "LOSE"
else -> "UNKNOWN"
},
outcomeIndex = position.outcomeIndex,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(settlementTrade)
batchTradesToSave.add(settlementTrade)
// 移除已结算的持仓
positions.remove(positionKey)
} catch (e: Exception) {
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
}
}
return balance
}
/**
* 结算未到期持仓(回测结束时剩余持仓按均价平仓)
* @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库
*/
private suspend fun settleRemainingPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
settlementsToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
val quantity = position.quantity
val avgPrice = position.avgPrice
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val closedTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = "CLOSED",
outcomeIndex = position.outcomeIndex,
quantity = quantity,
price = avgPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(closedTrade)
settlementsToSave.add(closedTrade)
}
positions.clear()
return balance
}
/**
* 计算统计数据
*/
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
val buyTrades = trades.count { it.side == "BUY" }
val sellTrades = trades.count { it.side == "SELL" }
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
var totalProfit = BigDecimal.ZERO
var totalLoss = BigDecimal.ZERO
var maxProfit = BigDecimal.ZERO
var maxLoss = BigDecimal.ZERO
// 计算最大回撤
var runningBalance = if (trades.isNotEmpty()) {
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
var peakBalance = runningBalance
var maxDrawdown = BigDecimal.ZERO
for (i in trades.indices) {
val trade = trades[i]
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
if (trade.profitLoss != null) {
val pnl = trade.profitLoss.toSafeBigDecimal()
if (pnl > BigDecimal.ZERO) {
totalProfit += pnl
if (pnl > maxProfit) maxProfit = pnl
} else {
totalLoss += pnl
if (pnl < maxLoss) maxLoss = pnl
}
}
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
runningBalance = balance
}
// 计算平均持仓时间
var avgHoldingTime: Long? = null
if (trades.size > 1) {
var totalHoldingTime = 0L
var count = 0
for (i in 0 until trades.size - 1) {
val currentTrade = trades[i]
val nextTrade = trades[i + 1]
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
totalHoldingTime += holdingTime
count++
}
}
if (count > 0) {
avgHoldingTime = totalHoldingTime / count
}
}
return BacktestStatisticsDto(
totalTrades = trades.size,
buyTrades = buyTrades,
sellTrades = sellTrades,
winTrades = winTrades,
lossTrades = lossTrades,
winRate = if (buyTrades + sellTrades > 0) {
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
.multiply(BigDecimal("100"))
.toPlainString()
} else {
BigDecimal.ZERO.toPlainString()
},
maxProfit = maxProfit.toPlainString(),
maxLoss = maxLoss.toPlainString(),
maxDrawdown = maxDrawdown.toPlainString(),
avgHoldingTime = avgHoldingTime
)
}
/**
* 计算跟单金额
*/
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
return if (task.copyMode == "RATIO") {
// 比例模式:Leader 成交金额 × 跟单比例
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
} else {
// 固定金额模式:使用配置的固定金额
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
}
}
/**
* 判断是否同一天
*/
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
}
/**
* 格式化时间戳
*/
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date(timestamp))
}
/**
* 格式化日期(用于缓存key)
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd")
return sdf.format(Date(timestamp))
}
}
@@ -0,0 +1,113 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ThreadPoolExecutor
import kotlinx.coroutines.runBlocking
/**
* 回测轮询服务
* 定时获取待执行的回测任务并执行
*/
@Service
class BacktestPollingService(
private val backtestTaskRepository: BacktestTaskRepository,
private val executionService: BacktestExecutionService
) {
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
// 线程池:同一时刻只执行一个任务
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
/**
* 轮询待执行的回测任务
* 每 10 秒执行一次
* 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的
*/
@Scheduled(fixedDelay = 10000) // 10 秒
fun pollPendingTasks() {
try {
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
if (runningTasks.isNotEmpty()) {
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
val activeCount = (executor as ThreadPoolExecutor).activeCount
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
// 重置这些任务的状态为 PENDING,以便恢复执行
if (activeCount == 0 && runningTasks.isNotEmpty()) {
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
runningTasks.forEach { task ->
val now = System.currentTimeMillis()
val executionStartedAt = task.executionStartedAt
val executionDuration = if (executionStartedAt != null) {
now - executionStartedAt
} else {
0L
}
// 如果任务执行时间超过 1 分钟,认为是异常状态
if (executionDuration > 60000) {
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
task.status = "PENDING"
task.updatedAt = now
backtestTaskRepository.save(task)
}
}
} else {
logger.debug("${runningTasks.size} 个任务正在执行,跳过本次轮询")
return
}
}
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
.sortedBy { it.createdAt }
if (pendingTasks.isEmpty()) {
return
}
// 3. 只执行最早创建的任务
val taskToExecute = pendingTasks.first()
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
// 4. 提交任务到线程池执行
executor.submit {
try {
// 执行前再次检查任务状态(防止并发执行)
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (currentTask == null || currentTask.status != "PENDING") {
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
return@submit
}
runBlocking {
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500")
executionService.executeBacktest(currentTask, page = 0, size = 500)
}
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
// 更新任务状态为 FAILED
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (failedTask != null) {
failedTask.status = "FAILED"
failedTask.errorMessage = e.message
failedTask.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(failedTask)
}
}
}
} catch (e: Exception) {
logger.error("轮询回测任务失败", e)
}
}
}
@@ -0,0 +1,426 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.fromJson
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 回测任务服务
*/
@Service
class BacktestService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val leaderRepository: LeaderRepository,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
/**
* 创建回测任务
*/
@Transactional
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
return try {
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 2. 验证回测天数
if (request.backtestDays < 1 || request.backtestDays > 15) {
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
}
// 3. 验证恢复页码(如果提供)
if (request.pageForResume != null && request.pageForResume < 1) {
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
}
// 4. 验证初始金额
val initialBalance = request.initialBalance.toSafeBigDecimal()
if (initialBalance <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
}
// 4. 创建回测任务
val task = BacktestTask(
taskName = request.taskName.trim(),
leaderId = request.leaderId,
initialBalance = initialBalance,
backtestDays = request.backtestDays,
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
status = "PENDING",
// 跟单配置(不包含 max_position_count
copyMode = request.copyMode ?: "RATIO",
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
maxDailyOrders = request.maxDailyOrders ?: 100,
supportSell = request.supportSell ?: true,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
request.keywords.toJson()
} else {
null
},
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
backtestTaskRepository.save(task)
// 5. 转换为 DTO 返回
Result.success(task.toDto(leader))
} catch (e: Exception) {
logger.error("创建回测任务失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务列表
*/
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
return try {
// 获取所有符合条件的任务
val allTasks = when {
request.leaderId != null && request.status != null -> {
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
}
request.leaderId != null -> {
backtestTaskRepository.findByLeaderId(request.leaderId)
.filter { request.status == null || it.status == request.status }
}
request.status != null -> {
backtestTaskRepository.findByStatus(request.status)
}
else -> {
backtestTaskRepository.findAll()
}
}
// 排序
val sortedTasks = when (request.sortBy) {
"profitAmount" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitAmount }
} else {
allTasks.sortedByDescending { it.profitAmount }
}
}
"profitRate" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitRate }
} else {
allTasks.sortedByDescending { it.profitRate }
}
}
else -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.createdAt }
} else {
allTasks.sortedByDescending { it.createdAt }
}
}
}
// 分页
val total = sortedTasks.size
val pagedTasks = sortedTasks
.drop((request.page - 1) * request.size)
.take(request.size)
val list = pagedTasks.map { task ->
val leader = leaderRepository.findById(task.leaderId).orElse(null)
task.toDto(leader)
}
Result.success(
BacktestListResponse(
list = list,
total = total.toLong(),
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测任务列表失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务详情
*/
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
val leader = leaderRepository.findById(task.leaderId).orElse(null)
val config = BacktestConfigDto(
copyMode = task.copyMode,
copyRatio = task.copyRatio.toPlainString(),
fixedAmount = task.fixedAmount?.toPlainString(),
maxOrderSize = task.maxOrderSize.toPlainString(),
minOrderSize = task.minOrderSize.toPlainString(),
maxDailyLoss = task.maxDailyLoss.toPlainString(),
maxDailyOrders = task.maxDailyOrders,
supportSell = task.supportSell,
keywordFilterMode = task.keywordFilterMode,
keywords = if (task.keywords != null) {
task.keywords.fromJson<List<String>>()
} else {
emptyList()
},
maxPositionValue = task.maxPositionValue?.toPlainString(),
minPrice = task.minPrice?.toPlainString(),
maxPrice = task.maxPrice?.toPlainString()
)
val statistics = BacktestStatisticsDto(
totalTrades = task.totalTrades,
buyTrades = task.buyTrades,
sellTrades = task.sellTrades,
winTrades = task.winTrades,
lossTrades = task.lossTrades,
winRate = task.winRate?.toPlainString() ?: "0.00",
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
avgHoldingTime = task.avgHoldingTime
)
val taskDto = task.toDto(leader)
Result.success(
BacktestDetailResponse(
task = taskDto,
config = config,
statistics = statistics
)
)
} catch (e: Exception) {
logger.error("查询回测任务详情失败", e)
Result.failure(e)
}
}
/**
* 查询回测交易记录
*/
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
return try {
val pageRequest = PageRequest.of(
request.page - 1,
request.size,
Sort.by(Sort.Order.asc("tradeTime"))
)
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
request.taskId,
pageRequest
)
val list = tradesPage.content.map { trade ->
BacktestTradeDto(
id = trade.id!!,
tradeTime = trade.tradeTime,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
side = trade.side,
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
quantity = trade.quantity.toPlainString(),
price = trade.price.toPlainString(),
amount = trade.amount.toPlainString(),
fee = trade.fee.toPlainString(),
profitLoss = trade.profitLoss?.toPlainString(),
balanceAfter = trade.balanceAfter.toPlainString(),
leaderTradeId = trade.leaderTradeId
)
}
Result.success(
BacktestTradeListResponse(
list = list,
total = tradesPage.totalElements,
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测交易记录失败", e)
Result.failure(e)
}
}
/**
* 删除回测任务
*/
@Transactional
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
}
backtestTaskRepository.deleteById(request.id)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除回测任务失败", e)
Result.failure(e)
}
}
/**
* 停止回测任务
*/
@Transactional
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status != "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
}
task.status = "STOPPED"
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("停止回测任务失败", e)
Result.failure(e)
}
}
/**
* 重试回测任务
* 从断点继续执行,保留已处理的交易记录
*/
@Transactional
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
}
// 重置任务状态为 PENDING,进度保持不变
task.status = "PENDING"
task.errorMessage = null
task.updatedAt = System.currentTimeMillis()
// 不清理已处理的交易记录,保留恢复点
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("重试回测任务失败", e)
Result.failure(e)
}
}
/**
* 按当前配置重新测试:基于已完成的回测任务创建一份相同配置的新任务(名称可修改)
*/
@Transactional
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
return try {
val source = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (source.status != "COMPLETED") {
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
}
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
?: "${source.taskName} (副本)"
val newTask = BacktestTask(
taskName = newTaskName,
leaderId = source.leaderId,
initialBalance = source.initialBalance,
backtestDays = source.backtestDays,
startTime = source.startTime,
status = "PENDING",
copyMode = source.copyMode,
copyRatio = source.copyRatio,
fixedAmount = source.fixedAmount,
maxOrderSize = source.maxOrderSize,
minOrderSize = source.minOrderSize,
maxDailyLoss = source.maxDailyLoss,
maxDailyOrders = source.maxDailyOrders,
supportSell = source.supportSell,
keywordFilterMode = source.keywordFilterMode,
keywords = source.keywords,
maxPositionValue = source.maxPositionValue,
minPrice = source.minPrice,
maxPrice = source.maxPrice
)
backtestTaskRepository.save(newTask)
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
Result.success(newTask.toDto(leader))
} catch (e: Exception) {
logger.error("按配置重新测试失败", e)
Result.failure(e)
}
}
}
/**
* 扩展函数:BacktestTask 转 DTO
*/
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
return BacktestTaskDto(
id = this.id!!,
taskName = this.taskName,
leaderId = this.leaderId,
leaderName = leader?.leaderName,
leaderAddress = leader?.leaderAddress,
initialBalance = this.initialBalance.toPlainString(),
finalBalance = this.finalBalance?.toPlainString(),
profitAmount = this.profitAmount?.toPlainString(),
profitRate = this.profitRate?.toPlainString(),
backtestDays = this.backtestDays,
startTime = this.startTime,
endTime = this.endTime,
status = this.status,
progress = this.progress,
totalTrades = this.totalTrades,
createdAt = this.createdAt,
executionStartedAt = this.executionStartedAt,
executionFinishedAt = this.executionFinishedAt
)
}
@@ -7,11 +7,15 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
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
@@ -26,8 +30,6 @@ import java.math.BigInteger
*/
@Service
class BlockchainService(
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
private val dataApiBaseUrl: String,
private val retrofitFactory: RetrofitFactory,
private val relayClientService: RelayClientService,
private val rpcNodeService: RpcNodeService,
@@ -61,10 +63,10 @@ class BlockchainService(
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
dataApiBaseUrl.dropLast(1)
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
} else {
dataApiBaseUrl
PolymarketConstants.DATA_API_BASE_URL
}
val okHttpClient = createClient()
.followRedirects(true)
@@ -92,13 +94,13 @@ class BlockchainService(
* 2. Safe ProxyMetaMask 钱包用户)- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址(EOA
* @param walletType 钱包类型:"magic"(默认)或 "safe"
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
when (walletType) {
WalletType.SAFE -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
@@ -109,7 +111,7 @@ class BlockchainService(
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
WalletType.MAGIC -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
@@ -257,7 +259,6 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
@@ -266,6 +267,82 @@ class BlockchainService(
Result.failure(e)
}
}
/**
* 查询钱包余额(通用方法)
* 用于 Account 和 Leader 的余额查询
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
*/
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
return try {
if (walletAddress.isBlank()) {
logger.error("钱包地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
}
// 1. 查询持仓信息(用于返回持仓列表)
val positionsResult = getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
// 过滤掉价值为0的仓位
positionsResult.getOrNull()?.filter { pos ->
val currentValue = pos.currentValue ?: 0.0
currentValue > 0
}?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
title = pos.title,
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 2. 使用 /value 接口获取仓位总价值
val positionBalanceResult = getTotalValue(walletAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
val availableBalanceResult = getUsdcBalance(
walletAddress = walletAddress,
proxyAddress = walletAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 4. 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
Result.success(
WalletBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
)
} catch (e: Exception) {
logger.error("查询钱包余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
@@ -504,39 +581,35 @@ class BlockchainService(
/**
* 赎回仓位
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
*
* Safe 账户通过代理 execTransaction 调用Magic 账户通过 Builder Relayer PROXYGasless)执行
*
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
* @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
indexSets: List<BigInteger>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
@@ -89,8 +89,6 @@ class MarketPollingService(
*/
private suspend fun checkAndUpdateMissingMarkets() {
try {
logger.debug("开始检查缺失的市场信息...")
// 1. 获取所有买入订单的市场ID(去重)
val allOrders = copyOrderTrackingRepository.findAll()
val marketIds = allOrders.map { it.marketId }.distinct()
@@ -99,9 +97,6 @@ class MarketPollingService(
logger.debug("没有找到任何订单,跳过市场信息检查")
return
}
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
// 2. 检查哪些市场信息在数据库中缺失
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
@@ -113,7 +108,6 @@ class MarketPollingService(
}
if (validMissingMarketIds.isEmpty()) {
logger.debug("所有市场信息都已存在,无需更新")
return
}
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
/**
* 市场价格服务
@@ -27,6 +29,21 @@ class MarketPriceService(
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 已结算市场的价格缓存
* Key: "marketId:outcomeIndex"
* Value: BigDecimal (1.0 0.0)
*
* 缓存策略
* - 最大缓存 10,000 个已结算市场
* - 永不过期已结算的市场状态永不改变
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
*/
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计信息
.build()
/**
* 获取当前市场最新价
* 优先级
@@ -83,9 +100,20 @@ class MarketPriceService(
* - payout == 0输了 返回 0.0
* 如果市场未结算或查询失败返回 null
*
* 使用缓存优化已结算的市场结果会被缓存避免重复 RPC 调用
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格如果已结算第二个值表示是否发生了 RPC 错误execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
// 1. 先检查缓存
val cacheKey = "$marketId:$outcomeIndex"
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
if (cachedPrice != null) {
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
return Pair(cachedPrice, false)
}
// 2. 缓存未命中,发起 RPC 查询
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
@@ -96,11 +124,17 @@ class MarketPriceService(
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ONE, false)
val price = BigDecimal.ONE
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ZERO, false)
val price = BigDecimal.ZERO
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
@@ -109,7 +143,7 @@ class MarketPriceService(
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false)
Pair(null, false) // 未结算的市场不缓存
}
},
onFailure = { e ->
@@ -290,5 +324,29 @@ class MarketPriceService(
}
}
/**
* 获取缓存统计信息
* 用于监控缓存命中率和性能
*/
fun getCacheStats(): String {
val stats = settledMarketCache.stats()
return """
已结算市场缓存统计:
- 缓存条目数: ${settledMarketCache.estimatedSize()}
- 命中次数: ${stats.hitCount()}
- 未命中次数: ${stats.missCount()}
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
- 总请求次数: ${stats.requestCount()}
""".trimIndent()
}
/**
* 清空缓存测试或管理用
*/
fun clearSettledMarketCache() {
settledMarketCache.invalidateAll()
logger.info("已清空已结算市场缓存")
}
}
@@ -3,12 +3,12 @@ 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.constants.PolymarketConstants
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
*/
@Service
class PolymarketApiKeyService(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
private val gson: Gson
) {
@@ -224,7 +222,7 @@ class PolymarketApiKeyService(
.build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -238,7 +236,7 @@ class PolymarketApiKeyService(
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.DateUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
@@ -45,7 +46,8 @@ class CopyTradingFilterService(
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
marketTitle: String? = null, // 市场标题,用于关键字过滤
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
): FilterResult {
// 1. 关键字过滤检查(如果配置了关键字过滤)
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
@@ -71,12 +73,20 @@ class CopyTradingFilterService(
}
}
// 3. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
// 3. 检查是否需要获取订单簿或需要执行仓位检查
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
if (!needOrderbook) {
// 不需要订单簿,直接通过
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
if (!positionCheck.isPassed) {
return positionCheck
}
}
// 通过所有检查
return FilterResult.passed()
}
@@ -108,7 +118,7 @@ class CopyTradingFilterService(
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
if (!positionCheck.isPassed) {
return positionCheck
}
@@ -193,12 +203,16 @@ class CopyTradingFilterService(
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
}
return FilterResult.passed()
@@ -236,7 +250,9 @@ class CopyTradingFilterService(
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
val spreadStr = spread.stripTrailingZeros().toPlainString()
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
}
return FilterResult.passed()
@@ -276,94 +292,86 @@ class CopyTradingFilterService(
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
}
return FilterResult.passed()
}
/**
* 检查仓位限制按市场检查
* 检查仓位限制按市场+方向检查
* @param copyTrading 跟单配置
* @param copyOrderAmount 跟单金额USDC
* @param marketId 市场ID用于过滤该市场的仓位
* @param outcomeIndex 方向索引0, 1, 2, ...用于按市场+方向检查仓位
* @return 过滤结果
*/
private suspend fun checkPositionLimits(
copyTrading: CopyTrading,
copyOrderAmount: BigDecimal,
marketId: String
marketId: String,
outcomeIndex: Int?
): FilterResult {
// 如果未配置仓位限制,直接通过
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
if (copyTrading.maxPositionValue == null) {
return FilterResult.passed()
}
try {
// 获取账户的所有仓位信息
val positionsResult = accountService.getAllPositions()
if (positionsResult.isFailure) {
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
// 如果获取仓位失败,为了安全起见,不通过检查
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
}
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
// 过滤出当前账户且该市场的仓位
val marketPositions = positions.currentPositions.filter {
val marketPositions = positions.currentPositions.filter {
it.accountId == copyTrading.accountId && it.marketId == marketId
}
// 检查最大仓位金额(如果配置了)
if (copyTrading.maxPositionValue != null) {
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
// 按市场+方向(outcomeIndex)分别计算数据库成本价
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
copyTrading.id!!, marketId, outcomeIndex
) ?: BigDecimal.ZERO
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
val extValue = if (marketPositions.isNotEmpty()) {
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
} else {
BigDecimal.ZERO
}
// 取数据库值和外部持仓值的最大值
val currentPositionValue = dbValue.max(extValue)
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
val extValueStr = extValue.stripTrailingZeros().toPlainString()
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
return FilterResult.maxPositionValueFailed(
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
)
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
val extCount = positions.currentPositions
.filter { it.accountId == copyTrading.accountId }
.map { it.marketId }
.distinct()
.size
val currentPositionCount = maxOf(dbCount, extCount)
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
copyTrading.id, marketId, BigDecimal.ZERO
)
val hasExtPosition = marketPositions.isNotEmpty()
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
)
}
}
return FilterResult.passed()
} catch (e: Exception) {
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
// 如果检查异常,为了安全起见,不通过检查
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
}
@@ -394,8 +402,10 @@ class CopyTradingFilterService(
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
)
}
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@@ -30,10 +32,24 @@ class CopyTradingService(
private val monitorService: CopyTradingMonitorService,
private val jsonUtils: JsonUtils,
private val gson: Gson
) {
) : ApplicationContextAware {
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
private var applicationContext: ApplicationContext? = null
override fun setApplicationContext(applicationContext: ApplicationContext) {
this.applicationContext = applicationContext
}
/**
* 获取代理对象用于解决 @Transactional 自调用问题
*/
private fun getSelf(): CopyTradingService {
return applicationContext?.getBean(CopyTradingService::class.java)
?: throw IllegalStateException("ApplicationContext not initialized")
}
/**
* 创建跟单配置
* 支持两种方式
@@ -84,7 +100,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -116,7 +131,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -148,7 +162,6 @@ class CopyTradingService(
minPrice = config.minPrice,
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
@@ -266,16 +279,6 @@ class CopyTradingService(
} else {
copyTrading.maxPositionValue
},
// 处理 maxPositionCount-1 表示要清空(设置为 null),null 表示不更新
maxPositionCount = if (request.maxPositionCount != null) {
if (request.maxPositionCount == -1) {
null
} else {
request.maxPositionCount
}
} else {
copyTrading.maxPositionCount
},
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
@@ -331,7 +334,7 @@ class CopyTradingService(
*/
@Transactional
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
return updateCopyTrading(
return getSelf().updateCopyTrading(
CopyTradingUpdateRequest(
copyTradingId = request.copyTradingId,
enabled = request.enabled
@@ -504,7 +507,6 @@ class CopyTradingService(
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
@@ -569,7 +571,6 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
@@ -20,8 +20,6 @@ enum class FilterStatus {
FAILED_ORDER_DEPTH,
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
@@ -87,12 +85,6 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
reason = reason
)
/** 超过最大仓位数量 */
fun maxPositionCountFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
@@ -3,12 +3,15 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.CategoryValidator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlinx.coroutines.runBlocking
/**
* Leader 管理服务
@@ -17,9 +20,11 @@ import org.springframework.transaction.annotation.Transactional
class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository
private val copyTradingRepository: CopyTradingRepository,
private val backtestTaskRepository: BacktestTaskRepository,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
/**
@@ -154,7 +159,8 @@ class LeaderService(
val leaderDtos = leaders.map { leader ->
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
toDto(leader, copyTradingCount)
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
toDto(leader, copyTradingCount, backtestCount)
}
Result.success(
@@ -176,19 +182,54 @@ class LeaderService(
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount))
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
Result.success(toDto(leader, copyTradingCount, backtestCount))
} catch (e: Exception) {
logger.error("查询 Leader 详情失败", e)
Result.failure(e)
}
}
/**
* 查询 Leader 余额
* 使用代理地址查询 USDC 余额和持仓信息
*/
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// Leader 的 leaderAddress 就是代理地址
val walletAddress = leader.leaderAddress
// 使用通用方法查询余额
val balanceResult = runBlocking {
blockchainService.getWalletBalance(walletAddress)
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
LeaderBalanceResponse(
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询 Leader 余额失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
*/
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
return LeaderDto(
id = leader.id!!,
leaderAddress = leader.leaderAddress,
@@ -197,6 +238,7 @@ class LeaderService(
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
backtestCount = backtestCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
)
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import com.wrbug.polymarketbot.constants.PolymarketConstants
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
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
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 websocketUrl: String = PolymarketConstants.USER_WS_URL
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
@@ -214,7 +213,7 @@ class CopyTradingWebSocketService(
// 处理交易
scope.launch {
try {
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
} catch (e: Exception) {
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
@@ -1,5 +1,8 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.google.gson.JsonNull
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
@@ -11,6 +14,7 @@ import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* 链上 WebSocket 监听服务
@@ -23,12 +27,17 @@ class OnChainWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.build()
/**
* 启动链上 WebSocket 监听
* 通过统一服务订阅所有 Leader
@@ -40,14 +49,14 @@ class OnChainWsService(
stop()
return
}
// 更新 Leader 列表
monitoredLeaders.clear()
leaders.forEach { leader ->
addLeader(leader)
}
}
/**
* 添加Leader监听
* 通过统一服务订阅该 Leader 的地址
@@ -57,17 +66,17 @@ class OnChainWsService(
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(
@@ -79,40 +88,53 @@ class OnChainWsService(
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) {
private suspend fun handleLeaderTransaction(
leaderId: Long,
txHash: String,
httpClient: OkHttpClient,
rpcApi: EthereumRpcApi
) {
val leader = monitoredLeaders[leaderId] ?: return
// 根据 txHash 去重(使用原子操作避免竞态条件)
val currentTime = System.currentTimeMillis()
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
if (existingTimestamp != null) {
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
return
}
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result is JsonNull) {
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
@@ -120,7 +142,7 @@ class OnChainWsService(
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: run {
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
@@ -128,7 +150,7 @@ class OnChainWsService(
}
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
txHash = txHash,
@@ -138,7 +160,7 @@ class OnChainWsService(
erc1155Transfers = erc1155Transfers,
retrofitFactory = retrofitFactory
)
if (trade != null) {
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
// 调用 processTrade 处理交易
@@ -154,21 +176,21 @@ class OnChainWsService(
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")
}
/**
* 停止监听
*/
@@ -180,7 +202,7 @@ class OnChainWsService(
}
monitoredLeaders.clear()
}
@PreDestroy
fun destroy() {
stop()
@@ -1,5 +1,7 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.dto.ActivityTradePayload
@@ -7,18 +9,19 @@ 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.fromJson
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* Polymarket Activity WebSocket 监听服务
* 通过订阅全局 activity 交易流客户端过滤 Leader 地址实现实时交易检测
* 通过订阅全局 activity 交易流trades + orders_matched客户端过滤 Leader 地址实现实时交易检测
* 延迟 < 100ms适合快速跟单场景
*/
@Service
@@ -29,8 +32,7 @@ class PolymarketActivityWsService(
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -40,10 +42,30 @@ class PolymarketActivityWsService(
// 要监听的 Leader 地址集合(小写地址 -> leaderId
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build()
// 是否已订阅
@Volatile
private var isSubscribed = false
// 最后一次收到 activity 消息的时间(毫秒时间戳)
@Volatile
private var lastActivityTime: Long = 0
// Activity 消息超时检测任务
private var activityTimeoutJob: Job? = null
// 性能统计
private var totalMessagesProcessed = 0L
private var addressMatchMessages = 0L
private var jsonParseMessages = 0L
private var duplicateTxHashMessages = 0L
/**
* 启动监听
*/
@@ -62,7 +84,7 @@ class PolymarketActivityWsService(
return
}
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
logger.info("启动 Activity WebSocket 监听trades + orders_matched,监控 ${monitoredAddresses.size} 个 Leader 地址")
connectAndSubscribe()
}
@@ -159,6 +181,7 @@ class PolymarketActivityWsService(
* 订阅全局 activity
* 根据 @polymarket/real-time-data-client 的协议格式
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
* 同时订阅 trades orders_matched 两种类型
*/
private fun subscribeAllActivity() {
val client = wsClient
@@ -170,6 +193,7 @@ class PolymarketActivityWsService(
try {
// 根据 real-time-data-client 的协议格式
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
// 同时订阅 trades 和 orders_matched 两种类型
val subscribeMessage = """
{
"action": "subscribe",
@@ -177,6 +201,10 @@ class PolymarketActivityWsService(
{
"topic": "activity",
"type": "trades"
},
{
"topic": "activity",
"type": "orders_matched"
}
]
}
@@ -184,46 +212,154 @@ class PolymarketActivityWsService(
client.sendMessage(subscribeMessage)
isSubscribed = true
logger.info("Activity WebSocket 订阅成功(全局交易流)")
// 重置最后一次收到 activity 消息的时间
lastActivityTime = System.currentTimeMillis()
// 启动 Activity 消息超时检测
// startActivityTimeoutCheck()
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched")
} catch (e: Exception) {
logger.error("订阅 Activity WebSocket 失败", e)
isSubscribed = false
}
}
/**
* 启动 Activity 消息超时检测
* 每30秒检查一次如果超过30秒没有收到activity消息则重连
*/
private fun startActivityTimeoutCheck() {
// 先停止之前的检测任务
stopActivityTimeoutCheck()
activityTimeoutJob = scope.launch {
while (isActive && isSubscribed) {
delay(30000) // 每30秒检查一次
// 如果已经取消订阅,停止检测
if (!isSubscribed) {
break
}
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
if (lastActivityTime == 0L) {
continue
}
val currentTime = System.currentTimeMillis()
val timeSinceLastActivity = currentTime - lastActivityTime
// 如果超过30秒没有收到activity消息,触发重连
if (timeSinceLastActivity >= 30000) {
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
// 关闭当前连接并重连
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
// 重新连接
connectAndSubscribe()
break // 重连后会重新启动检测任务
}
}
}
}
/**
* 停止 Activity 消息超时检测
*/
private fun stopActivityTimeoutCheck() {
activityTimeoutJob?.cancel()
activityTimeoutJob = null
}
/**
* 检查消息是否包含监听的 Leader 地址
* 快速过滤避免不必要的 JSON 解析
* 只需要检查 "proxyWallet":"0x..." "trader":{"address":"0x..."} 格式
*/
private fun containsMonitoredAddress(message: String): Boolean {
// 快速检查:如果消息很短,不可能包含地址
if (message.length < 50) {
return false
}
// 遍历所有监听的地址
for ((address, leaderId) in monitoredAddresses) {
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
addressMatchMessages++
return true
}
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
if (message.contains("\"trader\"", ignoreCase = true) &&
message.contains("\"address\":\"$address\"", ignoreCase = true)
) {
addressMatchMessages++
return true
}
}
return false
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
totalMessagesProcessed++
// 处理 PONG 响应
if (message.trim() == "PONG" || message.trim() == "pong") {
return
}
// 使用扩展函数解析消息
// 快速预检查:检查是否包含监听地址
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
if (!containsMonitoredAddress(message)) {
return
}
logger.info("发现leader交易:${message}")
// 使用扩展函数解析消息(只对包含监听地址的消息)
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
// 不是有效的 JSON 或格式不匹配,跳过
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
return
}
// 检查是否是 activity trade 消息
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
// 不是我们关心的消息,直接返回
jsonParseMessages++
// 检查是否是 activity 消息(trades 或 orders_matched
if (tradeMessage.topic != "activity" ||
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
return
}
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
lastActivityTime = System.currentTimeMillis()
val payload = tradeMessage.payload
// 根据 txHash 去重(使用原子操作避免竞态条件)
val txHash = payload.transactionHash
if (txHash != null && txHash.isNotBlank()) {
val currentTime = System.currentTimeMillis()
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
if (existingTimestamp != null) {
duplicateTxHashMessages++
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
return
}
}
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: run {
// 没有交易者地址,跳过
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
return
}
// 检查是否是我们监听的 Leader
// 二次验证:确认地址匹配
val normalizedAddress = traderAddress.lowercase()
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
return
@@ -383,10 +519,13 @@ class PolymarketActivityWsService(
*/
fun stop() {
logger.info("停止 Activity WebSocket 监听")
stopActivityTimeoutCheck()
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
monitoredAddresses.clear()
processedTxHashes.invalidateAll() // 清空去重缓存
lastActivityTime = 0
}
/**
@@ -403,8 +542,33 @@ class PolymarketActivityWsService(
return monitoredAddresses.size
}
/**
* 获取性能统计信息
*/
fun getPerformanceStats(): Map<String, Any> {
val jsonParseRate = if (totalMessagesProcessed > 0) {
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
} else {
0
}
return mapOf(
"totalMessages" to totalMessagesProcessed,
"addressMatches" to addressMatchMessages,
"jsonParses" to jsonParseMessages,
"duplicateTxHashes" to duplicateTxHashMessages,
"jsonParseRate" to "$jsonParseRate%",
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
} else {
0
}
)
}
@PreDestroy
fun destroy() {
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
stop()
scope.cancel()
}
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
}
addressConnections.clear()
}
/**
* 获取连接状态
* @return Map<address, isConnected>
*/
fun getConnectionStatuses(): Map<String, Boolean> {
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
}
@PostConstruct
fun init() {
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
}
@PreDestroy
fun destroy() {
stop()
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
return subscriptions.isEmpty()
}
fun isConnected(): Boolean {
return isConnected
}
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
@@ -18,6 +18,7 @@ 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 com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.service.common.MarketService
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -41,8 +42,7 @@ class OrderPushService(
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@Value("\${polymarket.rtds.ws-url}")
private lateinit var polymarketWsUrl: String
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
// 存储账户 ID 和对应的 WebSocket 连接
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
@@ -19,9 +19,19 @@ import java.math.RoundingMode
*/
@Service
class OrderSigningService {
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
/**
* 根据钱包类型返回 CLOB 订单签名类型
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
*/
fun getSignatureTypeForWalletType(walletType: String?): Int {
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
}
// Polygon 主网合约地址
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
private val CHAIN_ID = 137L
@@ -36,13 +46,11 @@ class OrderSigningService {
size = 2,
amount = 4
)
// 金额精度限制(根据 Polymarket API 要求)
// makerAmount (USDC) 最多 2 位小数
// takerAmount (shares) 最多 4 位小数
private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
// 价格有效范围(Polymarket API 要求)
private val MIN_PRICE = BigDecimal("0.01")
private val MAX_PRICE = BigDecimal("0.99")
/**
* 订单金额计算结果
*/
@@ -62,7 +70,9 @@ class OrderSigningService {
/**
* 计算订单金额makerAmount takerAmount
*
*
* 参考 clob-client/src/order-builder/helpers.ts getOrderRawAmounts 函数
*
* @param side BUY SELL
* @param size 数量shares
* @param price 价格0-1 之间
@@ -77,49 +87,59 @@ class OrderSigningService {
): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal()
// 对价格进行 roundNormal 处理(与 clob-client 保持一致)
var rawPrice = roundNormal(priceDecimal, roundConfig.price)
// 验证价格范围,如果超出则调整到最接近的有效值
// Polymarket API 要求: 0.01 <= price <= 0.99
if (rawPrice > MAX_PRICE) {
logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
rawPrice = MAX_PRICE
} else if (rawPrice < MIN_PRICE) {
logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
rawPrice = MIN_PRICE
}
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// makerAmount 是 USDC 金额,最多 2 位小数
// takerAmount 是 shares 数量,最多 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
// makerAmount = price * size,使用原始价格计算(与SDK保持一致)
// 先使用原始价格计算,然后再进行舍入,确保精度一致
var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
// 确保 makerAmount 精度(USDC,最多 2 位小数)
rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
// 确保 takerAmount 精度(shares,最多 4 位小数)
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
// takerAmount (shares) 使用 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, 4)
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
// makerAmount (USDC) 使用 2 位小数
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundDown(rawMakerAmt, 2)
}
}
// 转换为 wei6 位小数)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
} else {
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
// 根据 Polymarket API 要求:
// - makerAmount (shares) 最多 2 位小数
// - takerAmount (USDC) 最多 4 位小数
// 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
// SDK期望使用原始价格进行计算,以保留足够的精度
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
// 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
}
}
// 转换为 wei6 位小数)
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
}
}
@@ -324,23 +344,65 @@ class OrderSigningService {
/**
* 正常舍入四舍五入
* 参考 clob-client/src/utilities.ts roundNormal 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.HALF_UP)
}
/**
* 向下舍入
* 参考 clob-client/src/utilities.ts roundDown 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.DOWN)
}
/**
* 向上舍入
* 参考 clob-client/src/utilities.ts roundUp 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.UP)
}
/**
* 计算 BigDecimal 的小数位数
* 参考 clob-client/src/utilities.ts decimalPlaces 函数
*
* @param value 要计算的数值
* @return 小数位数
*/
private fun decimalPlaces(value: BigDecimal): Int {
if (value.scale() <= 0) {
return 0
}
// 去除尾部的零,获取真实的小数位数
return value.stripTrailingZeros().scale()
}
}
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.CryptoUtils
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
) : ApplicationContextAware {
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var applicationContext: ApplicationContext? = null
override fun setApplicationContext(applicationContext: ApplicationContext) {
this.applicationContext = applicationContext
}
/**
* 获取代理对象用于解决 @Transactional 自调用问题
*/
private fun getSelf(): CopyOrderTrackingService {
return applicationContext?.getBean(CopyOrderTrackingService::class.java)
?: throw IllegalStateException("ApplicationContext not initialized")
}
// 使用 Mutex 保证线程安全(按交易ID锁定)
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
return@withLock Result.success(Unit)
}
// 2. 处理交易逻辑
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
val self = getSelf()
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
"BUY" -> self.processBuyTrade(leaderId, trade, source)
"SELL" -> self.processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
@@ -168,6 +185,7 @@ open class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
@@ -213,7 +231,7 @@ open class CopyOrderTrackingService(
* 创建跟单买入订单并记录到跟踪表
*/
@Transactional
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
return try {
// 1. 查找所有启用且支持该Leader的跟单关系
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
@@ -285,7 +303,7 @@ open class CopyOrderTrackingService(
// 过滤条件检查(在计算订单参数之前)
// 传入 Leader 交易价格,用于价格区间检查
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
// 传入市场标题,用于关键字过滤
// 传入市场截止时间,用于市场截止时间检查
// 订单簿只请求一次,返回给后续逻辑使用
@@ -296,7 +314,8 @@ open class CopyOrderTrackingService(
copyOrderAmount = copyOrderAmount,
marketId = trade.market,
marketTitle = marketTitle,
marketEndDate = marketEndDate
marketEndDate = marketEndDate,
outcomeIndex = trade.outcomeIndex
)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
@@ -551,7 +570,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
// 处理订单创建失败
@@ -622,7 +642,8 @@ open class CopyOrderTrackingService(
price = buyPrice, // 使用下单价格,临时值
remainingQuantity = finalBuyQuantity,
status = "filled",
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
source = source // 订单来源
)
copyOrderTrackingRepository.save(tracking)
@@ -685,8 +706,8 @@ open class CopyOrderTrackingService(
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
return when (copyTrading.copyMode) {
"RATIO" -> {
// 比例模式:Leader 数量 × (比例 / 100)
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
}
"FIXED" -> {
@@ -718,7 +739,7 @@ open class CopyOrderTrackingService(
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
?: run {
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
return leaderSellQuantity.multi(copyTrading.copyRatio)
}
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
@@ -789,7 +810,7 @@ open class CopyOrderTrackingService(
// 如果无法计算总比例(查询失败),使用默认比例
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
return leaderSellQuantity.multi(copyTrading.copyRatio)
}
// 计算实际比例:跟单买入数量 / Leader 买入数量
@@ -865,13 +886,13 @@ open class CopyOrderTrackingService(
}
"RATIO" -> {
// 比例模式:直接使用配置的 copyRatio (需要除以100)
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
}
else -> {
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
}
}
@@ -974,7 +995,7 @@ open class CopyOrderTrackingService(
"0"
}
// 9. 创建并签名卖出订单
// 9. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -983,7 +1004,7 @@ open class CopyOrderTrackingService(
side = "SELL",
price = sellPrice.toString(),
size = totalMatched.toString(),
signatureType = 2, // Browser Wallet
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
@@ -1024,7 +1045,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
if (createOrderResult.isFailure) {
@@ -1117,6 +1139,7 @@ open class CopyOrderTrackingService(
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @param signatureType 签名类型1=Magic, 2=Safe
* @return 成功返回订单ID失败返回异常
*/
private suspend fun createOrderWithRetry(
@@ -1130,7 +1153,8 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String
feeRateBps: String,
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1145,7 +1169,7 @@ open class CopyOrderTrackingService(
side = side,
price = price,
size = size,
signatureType = 2, // Browser Wallet
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
@@ -1411,7 +1435,6 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_SPREAD -> "SPREAD"
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
}
@@ -611,22 +611,22 @@ class CopyTradingStatisticsService(
// 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList()
val list = marketIds.map { marketId ->
val marketOrders = groups[marketId] ?: mutableListOf()
// 计算统计信息
val count = marketOrders.size.toLong()
val totalAmount = marketOrders.sumOf { order ->
order.quantity.toSafeBigDecimal().multi(order.price)
}
// 计算订单状态统计
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
val filledCount = marketOrders.count { it.status == "filled" }
val fullyMatched = fullyMatchedCount == marketOrders.size
val stats = MarketOrderStats(
count = count,
totalAmount = totalAmount.toString(),
@@ -636,10 +636,10 @@ class CopyTradingStatisticsService(
partiallyMatchedCount = partiallyMatchedCount.toLong(),
filledCount = filledCount.toLong()
)
// 排序(按创建时间倒序)
marketOrders.sortByDescending { it.createdAt }
// 转换为 DTO
val orderDtos = marketOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
@@ -662,7 +662,7 @@ class CopyTradingStatisticsService(
createdAt = order.createdAt
)
}
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
@@ -672,27 +672,35 @@ class CopyTradingStatisticsService(
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
}.sortedByDescending { group ->
// 找出该市场最近的买入订单时间
group.orders.mapNotNull { order ->
when (order) {
is BuyOrderInfo -> order.createdAt
else -> null
}
}.maxOrNull() ?: 0L
}
// 5. 分页
val page = (request.page ?: 1)
val limit = request.limit ?: 20
val total = list.size.toLong()
val start = (page - 1) * limit
val end = minOf(start + limit, list.size)
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
val response = MarketGroupedOrdersResponse(
list = pagedList,
total = total,
page = page,
limit = limit
)
Result.success(response)
} catch (e: Exception) {
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
Result.failure(e)
}
}
@@ -791,7 +799,15 @@ class CopyTradingStatisticsService(
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
}.sortedByDescending { group ->
// 找出该市场最近的卖出订单时间(与买入订单分组排序规则一致)
group.orders.mapNotNull { order ->
when (order) {
is SellOrderInfo -> order.createdAt
else -> null
}
}.maxOrNull() ?: 0L
}
// 5. 分页
val page = (request.page ?: 1)
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
import com.wrbug.polymarketbot.util.createClient
@@ -11,9 +12,9 @@ import org.slf4j.LoggerFactory
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 com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@@ -22,16 +23,6 @@ import java.util.concurrent.TimeUnit
*/
@Service
class ApiHealthCheckService(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
@Value("\${polymarket.data-api.base-url}")
private val dataApiBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String,
@Value("\${polymarket.rtds.ws-url}")
private val polymarketWsUrl: String,
@Value("\${polymarket.builder.relayer-url:}")
private val builderRelayerUrl: String,
private val rpcNodeService: RpcNodeService
) : ApplicationContextAware {
@@ -52,17 +43,6 @@ class ApiHealthCheckService(
}
}
/**
* 获取跟单 WebSocket 服务通过 ApplicationContext 避免循环依赖
*/
private fun getCopyTradingWebSocketService(): CopyTradingWebSocketService? {
return try {
applicationContext?.getBean(CopyTradingWebSocketService::class.java)
} catch (e: BeansException) {
null
}
}
/**
* 获取 RelayClientService通过 ApplicationContext 避免循环依赖
*/
@@ -74,6 +54,28 @@ class ApiHealthCheckService(
}
}
/**
* 获取 PolymarketActivityWsService通过 ApplicationContext 避免循环依赖
*/
private fun getPolymarketActivityWsService(): PolymarketActivityWsService? {
return try {
applicationContext?.getBean(PolymarketActivityWsService::class.java)
} catch (e: BeansException) {
null
}
}
/**
* 获取 UnifiedOnChainWsService通过 ApplicationContext 避免循环依赖
*/
private fun getUnifiedOnChainWsService(): UnifiedOnChainWsService? {
return try {
applicationContext?.getBean(UnifiedOnChainWsService::class.java)
} catch (e: BeansException) {
null
}
}
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
/**
@@ -89,7 +91,9 @@ class ApiHealthCheckService(
async { checkDataApi() },
async { checkGammaApi() },
async { checkPolygonRpc() },
async { checkPolymarketWebSocket() },
async { checkPolymarketRtdsWebSocket() },
async { checkPolymarketActivityWebSocket() },
async { checkUnifiedOnChainWebSocket() },
async { checkBuilderRelayerApi() },
async { checkGitHubApi() }
)
@@ -106,7 +110,7 @@ class ApiHealthCheckService(
* 检查 Polymarket CLOB API
*/
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
val url = "$clobBaseUrl/"
val url = "${PolymarketConstants.CLOB_BASE_URL}/"
checkApi("Polymarket CLOB API", url)
}
@@ -114,7 +118,7 @@ class ApiHealthCheckService(
* 检查 Polymarket Data API
*/
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
val url = "$dataApiBaseUrl/"
val url = "${PolymarketConstants.DATA_API_BASE_URL}/"
checkApi("Polymarket Data API", url)
}
@@ -131,7 +135,7 @@ class ApiHealthCheckService(
.build()
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
val url = "$gammaBaseUrl/markets"
val url = "${PolymarketConstants.GAMMA_BASE_URL}/markets"
val request = Request.Builder()
.url(url)
.get()
@@ -176,7 +180,7 @@ class ApiHealthCheckService(
logger.warn("检查 Polymarket Gamma API 失败", e)
ApiHealthCheckDto(
name = "Polymarket Gamma API",
url = "$gammaBaseUrl/markets",
url = "${PolymarketConstants.GAMMA_BASE_URL}/markets",
status = "error",
message = e.message ?: "连接失败"
)
@@ -194,69 +198,143 @@ class ApiHealthCheckService(
}
/**
* 检查 Polymarket WebSocket 连接状态
* 不显示延时只显示连接状态
* 检查 Polymarket RTDS WebSocket 连接状态
* 用于订单推送服务
*/
private suspend fun checkPolymarketWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
private suspend fun checkPolymarketRtdsWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
try {
// 检查订单推送服务的连接状态
val orderPushService = getOrderPushService()
val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
val orderPushConnected = orderPushStatuses.values.any { it }
val orderPushTotal = orderPushStatuses.size
val orderPushConnectedCount = orderPushStatuses.values.count { it }
val statuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
val total = statuses.size
val connected = statuses.values.count { it }
// 检查跟单 WebSocket 服务的连接状态
val copyTradingWebSocketService = getCopyTradingWebSocketService()
val copyTradingStatuses = copyTradingWebSocketService?.getConnectionStatuses() ?: emptyMap()
val copyTradingConnected = copyTradingStatuses.values.any { it }
val copyTradingTotal = copyTradingStatuses.size
val copyTradingConnectedCount = copyTradingStatuses.values.count { it }
// 计算总体状态
val totalConnections = orderPushTotal + copyTradingTotal
val connectedConnections = orderPushConnectedCount + copyTradingConnectedCount
val url = polymarketWsUrl
val hasAnyConnection = orderPushConnected || copyTradingConnected
if (totalConnections == 0) {
// 没有配置任何 WebSocket 连接
if (total == 0) {
ApiHealthCheckDto(
name = "Polymarket WebSocket",
url = url,
name = "Polymarket RTDS WebSocket",
url = PolymarketConstants.RTDS_WS_URL,
status = "skipped",
message = "未配置 WebSocket 连接"
message = "未配置账户连接"
)
} else if (hasAnyConnection) {
// 至少有一个连接是活跃的
val message = if (connectedConnections == totalConnections) {
"所有连接正常 ($connectedConnections/$totalConnections)"
} else if (connected > 0) {
val message = if (connected == total) {
"所有账户连接正常 ($connected/$total)"
} else {
"部分连接正常 ($connectedConnections/$totalConnections)"
"部分账户连接正常 ($connected/$total)"
}
ApiHealthCheckDto(
name = "Polymarket WebSocket",
url = url,
name = "Polymarket RTDS WebSocket",
url = PolymarketConstants.RTDS_WS_URL,
status = "success",
message = message
// 不设置 responseTimeWebSocket 不显示延时
)
} else {
// 所有连接都断开
ApiHealthCheckDto(
name = "Polymarket WebSocket",
url = url,
name = "Polymarket RTDS WebSocket",
url = PolymarketConstants.RTDS_WS_URL,
status = "error",
message = "所有连接断开 ($connectedConnections/$totalConnections)"
// 不设置 responseTimeWebSocket 不显示延时
message = "所有账户连接断开 (0/$total)"
)
}
} catch (e: Exception) {
logger.warn("检查 Polymarket WebSocket 状态失败", e)
logger.warn("检查 Polymarket RTDS WebSocket 状态失败", e)
ApiHealthCheckDto(
name = "Polymarket WebSocket",
url = polymarketWsUrl,
name = "Polymarket RTDS WebSocket",
url = PolymarketConstants.RTDS_WS_URL,
status = "error",
message = "检查失败:${e.message}"
)
}
}
/**
* 检查 Polymarket Activity WebSocket 连接状态
* 用于 Activity 全局交易流监听
*/
private suspend fun checkPolymarketActivityWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
try {
val activityWsService = getPolymarketActivityWsService()
val isConnected = activityWsService?.isConnected() ?: false
if (isConnected) {
ApiHealthCheckDto(
name = "Polymarket Activity WebSocket",
url = PolymarketConstants.ACTIVITY_WS_URL,
status = "success",
message = "连接正常"
)
} else {
ApiHealthCheckDto(
name = "Polymarket Activity WebSocket",
url = PolymarketConstants.ACTIVITY_WS_URL,
status = "error",
message = "连接断开"
)
}
} catch (e: Exception) {
logger.warn("检查 Polymarket Activity WebSocket 状态失败", e)
ApiHealthCheckDto(
name = "Polymarket Activity WebSocket",
url = PolymarketConstants.ACTIVITY_WS_URL,
status = "error",
message = "检查失败:${e.message}"
)
}
}
/**
* 检查统一链上 WebSocket 连接状态
* 用于监听链上事件
*/
private suspend fun checkUnifiedOnChainWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
try {
val unifiedOnChainWsService = getUnifiedOnChainWsService()
if (unifiedOnChainWsService == null) {
return@withContext ApiHealthCheckDto(
name = "链上 WebSocket",
url = rpcNodeService.getWsUrl(),
status = "error",
message = "服务未初始化"
)
}
// 检查连接状态
val statuses = unifiedOnChainWsService.getConnectionStatuses()
val total = statuses.size
val connected = statuses.values.count { it }
if (total == 0) {
ApiHealthCheckDto(
name = "链上 WebSocket",
url = rpcNodeService.getWsUrl(),
status = "skipped",
message = "未配置地址监听"
)
} else if (connected > 0) {
val message = if (connected == total) {
"所有地址连接正常 ($connected/$total)"
} else {
"部分地址连接正常 ($connected/$total)"
}
ApiHealthCheckDto(
name = "链上 WebSocket",
url = rpcNodeService.getWsUrl(),
status = "success",
message = message
)
} else {
ApiHealthCheckDto(
name = "链上 WebSocket",
url = rpcNodeService.getWsUrl(),
status = "error",
message = "所有地址连接断开 (0/$total)"
)
}
} catch (e: Exception) {
logger.warn("检查链上 WebSocket 状态失败", e)
ApiHealthCheckDto(
name = "链上 WebSocket",
url = rpcNodeService.getWsUrl(),
status = "error",
message = "检查失败:${e.message}"
)
@@ -390,19 +468,10 @@ class ApiHealthCheckService(
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
val relayClientService = getRelayClientService()
if (builderRelayerUrl.isBlank()) {
return@withContext ApiHealthCheckDto(
name = "Builder Relayer API",
url = "未配置",
status = "skipped",
message = "未配置 Builder Relayer URL"
)
}
if (relayClientService == null) {
return@withContext ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
url = PolymarketConstants.BUILDER_RELAYER_URL,
status = "error",
message = "服务未初始化"
)
@@ -411,7 +480,7 @@ class ApiHealthCheckService(
if (!relayClientService.isBuilderApiKeyConfigured()) {
return@withContext ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
url = PolymarketConstants.BUILDER_RELAYER_URL,
status = "skipped",
message = "Builder API Key 未配置"
)
@@ -423,7 +492,7 @@ class ApiHealthCheckService(
onSuccess = { responseTime ->
ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
url = PolymarketConstants.BUILDER_RELAYER_URL,
status = "success",
message = "连接成功",
responseTime = responseTime
@@ -432,7 +501,7 @@ class ApiHealthCheckService(
onFailure = { e ->
ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
url = PolymarketConstants.BUILDER_RELAYER_URL,
status = "error",
message = e.message ?: "连接失败"
)
@@ -442,7 +511,7 @@ class ApiHealthCheckService(
logger.warn("检查 Builder Relayer API 失败", e)
ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
url = PolymarketConstants.BUILDER_RELAYER_URL,
status = "error",
message = e.message ?: "连接失败"
)
@@ -0,0 +1,48 @@
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.repository.ProcessedTradeRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 已处理交易清理服务
* 定期清理过期的去重记录
*/
@Service
class ProcessedTradeCleanupService(
private val processedTradeRepository: ProcessedTradeRepository
) {
companion object {
private val logger = LoggerFactory.getLogger(ProcessedTradeCleanupService::class.java)
// 保留时间:1小时(3600000毫秒)
// 说明:重复订单通常10秒后就不会再出现,保留10分钟是为了安全起见
private const val RETENTION_MS = 600_000L
// 定时清理间隔:10分钟(600000毫秒)
private const val CLEANUP_INTERVAL_MS = 600_000L
}
/**
* 定时清理过期记录
* 每10分钟执行一次
*/
@Scheduled(fixedDelay = CLEANUP_INTERVAL_MS)
@Transactional
fun cleanupExpiredProcessedTrades() {
try {
val expireTime = System.currentTimeMillis() - RETENTION_MS
val deletedCount = processedTradeRepository.deleteByProcessedAtBefore(expireTime)
if (deletedCount > 0) {
logger.info("清理过期已处理交易记录: deletedCount=$deletedCount, expireTime=$expireTime")
}
} catch (e: Exception) {
logger.error("清理过期已处理交易记录失败", e)
}
}
}
@@ -3,11 +3,12 @@ package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.math.BigInteger
@@ -24,8 +25,6 @@ import java.math.BigInteger
*/
@Service
class RelayClientService(
@Value("\${polymarket.builder.relayer-url:}")
private val builderRelayerUrl: String,
private val retrofitFactory: RetrofitFactory,
private val systemConfigService: SystemConfigService,
private val rpcNodeService: RpcNodeService
@@ -42,6 +41,15 @@ class RelayClientService(
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE"
private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl)
@@ -54,10 +62,10 @@ class RelayClientService(
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return retrofitFactory.createBuilderRelayerApi(
relayerUrl = builderRelayerUrl,
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey!!,
secret = builderSecret!!,
passphrase = builderPassphrase!!
@@ -74,19 +82,19 @@ class RelayClientService(
builderSecret: String?,
builderPassphrase: String?
): Boolean {
return builderRelayerUrl.isNotBlank() &&
return PolymarketConstants.BUILDER_RELAYER_URL.isNotBlank() &&
builderApiKey != null && builderApiKey.isNotBlank() &&
builderSecret != null && builderSecret.isNotBlank() &&
builderPassphrase != null && builderPassphrase.isNotBlank()
}
/**
* 检查 Builder API Key 是否已配置
*/
fun isBuilderApiKeyConfigured(): Boolean {
return systemConfigService.isBuilderApiKeyConfigured()
}
/**
* 检查 Builder Relayer API 健康状态用于 API 健康检查
*/
@@ -95,24 +103,24 @@ class RelayClientService(
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
return Result.failure(IllegalStateException("Builder API Key 未配置"))
}
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = builderRelayerUrl,
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
val testAddress = "0x0000000000000000000000000000000000000000"
val startTime = System.currentTimeMillis()
val response = relayerApi.getDeployed(testAddress)
val responseTime = System.currentTimeMillis() - startTime
if (response.isSuccessful) {
Result.success(responseTime)
} else {
@@ -203,47 +211,291 @@ class RelayClientService(
}
/**
* 执行 Safe 交易通过 Proxy.execTransaction
* 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute()
*
* 优先使用 Builder RelayerGasless如果未配置则回退到手动发送交易
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTx Safe 交易对象
* @param safeTx 交易对象to/data/value
* @param walletType 钱包类型MAGIC 使用 PROXY GaslessSAFE 使用 Safe 流程
* @return 交易哈希
*/
suspend fun execute(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
safeTx: SafeTransaction,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 检查 Builder API Key 是否已配置
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
// 优先使用 Builder RelayerGasless
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(privateKey, proxyAddress, safeTx, builderApiKey!!, builderSecret!!, builderPassphrase!!)
if (walletType == WalletType.MAGIC) {
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API KeyGasless"))
}
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
return executeViaBuilderRelayerProxy(
privateKey,
proxyAddress,
safeTx,
builderApiKey!!,
builderSecret!!,
builderPassphrase!!
)
}
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(
privateKey,
proxyAddress,
safeTx,
builderApiKey!!,
builderSecret!!,
builderPassphrase!!
)
}
// 回退到手动发送交易(需要用户支付 gas)
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
return executeManually(privateKey, proxyAddress, safeTx)
} catch (e: Exception) {
logger.error("执行 Safe 交易失败: ${e.message}", e)
logger.error("执行交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 Builder Relayer 执行 PROXYMagic交易Gasless
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
*/
private suspend fun executeViaBuilderRelayerProxy(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction,
builderApiKey: String,
builderSecret: String,
builderPassphrase: String
): Result<String> {
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
}
val relayPayload = relayPayloadResponse.body()!!
val relayAddress = relayPayload.address
val nonce = relayPayload.nonce
val proxyCallData = encodeProxyTransactionData(safeTx)
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit
val gasLimit = try {
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
} catch (e: Exception) {
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
defaultProxyGasLimit
}
val structHash = createProxyStructHash(
from = fromAddress,
to = proxyFactoryAddress,
data = proxyCallData,
txFee = "0",
gasPrice = "0",
gasLimit = gasLimit,
nonce = nonce,
relayHubAddress = relayHubAddress,
relayAddress = relayAddress
)
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
val request = BuilderRelayerApi.TransactionRequest(
type = RELAYER_TYPE_PROXY,
from = fromAddress,
to = proxyFactoryAddress,
proxyWallet = proxyAddress,
data = proxyCallData,
nonce = nonce,
signature = sigHex,
signatureParams = BuilderRelayerApi.SignatureParams(
gasPrice = "0",
gasLimit = gasLimit,
relayerFee = "0",
relayHub = relayHubAddress,
relay = relayAddress
),
metadata = "Redeem positions via Builder Relayer PROXY"
)
val response = relayerApi.submitTransaction(request)
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
}
/**
* 编码 ProxyFactory.proxy(calls) 调用数据
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
*
* ABI 编码规则 tuple 数组中的 tuple 包含动态类型bytes需要先存储 tuple offset
* 结构
* - selector (4 bytes)
* - array offset (32 bytes) = 32
* - array length (32 bytes) = 1
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始 array length 之后计算)
* - tuple[0] 数据
* - typeCode (32 bytes) = 1
* - to (32 bytes)
* - value (32 bytes) = 0
* - data offset (32 bytes) = 128 ( tuple 数据开始计算)
* - data length (32 bytes)
* - data (padded to 32-byte boundary)
*/
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
val callData = safeTx.data.removePrefix("0x")
val dataLen = callData.length / 2
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
val dataPadded = callData.padEnd(dataLenPadded, '0')
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
// 1. array offset: 32 (指向 array length)
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 2. array length: 1
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 4. tuple[0] 数据:
// - typeCode: 1
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
// - to: address
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
// - value: 0
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
val dataOffsetInTuple = BigInteger.valueOf(128)
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
// - data length
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
// - data (padded)
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
dataLengthEncoded + dataPadded
}
/**
* 估算 PROXY 交易的 gas limit
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
*/
private suspend fun estimateProxyGasLimit(
from: String,
to: String,
data: String
): String {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_estimateGas",
params = listOf(
mapOf(
"from" to from,
"to" to to,
"data" to data
)
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
}
val hexGasLimit = rpcResponse.result?.asString
?: throw Exception("eth_estimateGas 结果为空")
// 将十六进制转换为十进制字符串
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
return gasLimitBigInt.toString()
}
/**
* 创建 PROXY 结构哈希参考 builder-relayer-client builder/proxy.ts createStructHash
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
*/
private fun createProxyStructHash(
from: String,
to: String,
data: String,
txFee: String,
gasPrice: String,
gasLimit: String,
nonce: String,
relayHubAddress: String,
relayAddress: String
): ByteArray {
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
return EthereumUtils.keccak256(concat)
}
/**
* 通过 Builder Relayer 执行交易Gasless
* 参考: builder-relayer-client/src/client.ts execute 方法
@@ -256,9 +508,8 @@ class RelayClientService(
builderSecret: String,
builderPassphrase: String
): Result<String> {
val rpcApi = polygonRpcApi
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = builderRelayerUrl,
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
@@ -274,7 +525,7 @@ class RelayClientService(
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
@@ -320,19 +571,19 @@ class RelayClientService(
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
// 对带前缀的消息进行 keccak256 哈希
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
@@ -341,7 +592,7 @@ class RelayClientService(
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
val request = BuilderRelayerApi.TransactionRequest(
type = "SAFE",
type = RELAYER_TYPE_SAFE,
from = fromAddress,
to = safeTx.to,
proxyWallet = proxyAddress,
@@ -358,7 +609,7 @@ class RelayClientService(
),
metadata = "Redeem positions via Builder Relayer"
)
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
// 调用 Builder Relayer API(认证头通过拦截器添加)
@@ -372,7 +623,7 @@ class RelayClientService(
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
@@ -381,14 +632,14 @@ class RelayClientService(
/**
* 打包签名参考 builder-relayer-client/src/utils/index.ts splitAndPackSig
* 将签名打包成 Gnosis Safe 接受的格式encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
*
*
* TypeScript 实现流程
* 1. 从签名字符串中提取 v最后 2 个字符
* 2. 调整 v 0,1 -> +31; 27,28 -> +4
* 3. 修改签名字符串替换最后 2 个字符
* 4. 从修改后的签名字符串中提取 r, s, v作为十进制字符串
* 5. 使用 encodePacked 打包uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
*
*
* 关键encodePacked 会将 BigInt 编码为 32 字节64 个十六进制字符uint8 编码为 1 字节2 个十六进制字符
*/
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
@@ -403,37 +654,37 @@ class RelayClientService(
}
val originalVHex = String.format("%02x", originalV)
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
val sigV = sigString.substring(sigString.length - 2).toInt(16)
// 3. 调整 v 值(参考 TypeScript 实现)
val adjustedV = when (sigV) {
0, 1 -> sigV + 31
27, 28 -> sigV + 4
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
}
// 4. 修改签名字符串(替换最后 2 个字符)
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt
val rBigInt = BigInteger(rHexStr, 16)
val sBigInt = BigInteger(sHexStr, 16)
val vInt = vHexStr.toInt(16)
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
return "0x$rEncoded$sEncoded$vEncoded"
}
@@ -504,13 +755,13 @@ class RelayClientService(
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
// 对带前缀的消息进行 keccak256 哈希
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
@@ -572,7 +823,8 @@ class RelayClientService(
redeemCallData: String,
safeSignatureHex: String
): String {
val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
val execFunctionSelector =
EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
val encodedTo = EthereumUtils.encodeAddress(safeTx.to)
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
@@ -600,20 +852,20 @@ class RelayClientService(
val encodedSignatures = safeSignatureHex
return "0x" + execFunctionSelector.removePrefix("0x") +
encodedTo +
encodedValue +
encodedDataOffset +
encodedDataLength +
encodedData +
encodedOperation +
encodedSafeTxGas +
encodedBaseGas +
encodedGasPrice +
encodedGasToken +
encodedRefundReceiver +
encodedSignaturesOffset +
encodedSignaturesLength +
encodedSignatures
encodedTo +
encodedValue +
encodedDataOffset +
encodedDataLength +
encodedData +
encodedOperation +
encodedSafeTxGas +
encodedBaseGas +
encodedGasPrice +
encodedGasToken +
encodedRefundReceiver +
encodedSignaturesOffset +
encodedSignaturesLength +
encodedSignatures
}
/**
@@ -98,8 +98,8 @@ class RpcNodeService(
.filterNot { isDefaultNode(it) } // 排除默认节点
if (nodes.isEmpty()) {
logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
logger.warn("没有配置任何启用的 RPC 节点,将使用默认节点")
return Result.success(createDefaultNodeConfig())
}
// 优先使用最近检查状态为 HEALTHY 的节点
@@ -136,39 +136,53 @@ class RpcNodeService(
}
}
// 所有节点都不可用,返回失败
logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
// 所有节点都不可用,返回默认节点
logger.warn("所有启用的 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
Result.success(createDefaultNodeConfig())
} catch (e: Exception) {
logger.error("获取可用节点失败: ${e.message}", e)
Result.failure(e)
// 即使失败也返回默认节点,确保系统可用
logger.warn("获取可用节点出现异常,使用默认节点作为兜底")
Result.success(createDefaultNodeConfig())
}
}
/**
* 创建默认节点配置
* 用于兜底确保系统始终有可用的 RPC 节点
*/
private fun createDefaultNodeConfig(): RpcNodeConfig {
return RpcNodeConfig(
id = 0L,
providerType = RpcProviderType.PUBLIC.name,
name = "默认节点",
httpUrl = DEFAULT_RPC_URL,
wsUrl = DEFAULT_WS_URL,
apiKey = null,
enabled = true,
priority = 9999,
lastCheckTime = System.currentTimeMillis(),
lastCheckStatus = NodeHealthStatus.HEALTHY.name,
responseTimeMs = null,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
/**
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
*/
fun getHttpUrl(): String {
val nodeResult = getAvailableNode()
return if (nodeResult.isSuccess) {
nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
} else {
logger.warn("没有可用的用户配置节点,使用默认节点")
DEFAULT_RPC_URL
}
val node = getAvailableNode().getOrNull()
return node?.httpUrl ?: 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
}
val node = getAvailableNode().getOrNull()
return node?.wsUrl ?: DEFAULT_WS_URL
}
/**
@@ -263,6 +277,13 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
}
// 检查是否禁用节点,如果是则清理缓存
val isDisabling = request.enabled == false && node.enabled == true
if (isDisabling) {
logger.info("节点被禁用,清理 RPC 缓存: ${node.httpUrl}")
retrofitFactory.clearRpcApiCache(node.httpUrl)
}
// 更新字段
val updatedNode = node.copy(
name = request.name ?: node.name,
@@ -272,7 +293,7 @@ class RpcNodeService(
)
val savedNode = rpcNodeConfigRepository.save(updatedNode)
logger.info("成功更新 RPC 节点: ${savedNode.name}")
logger.info("成功更新 RPC 节点: ${savedNode.name}, enabled=${savedNode.enabled}")
Result.success(savedNode)
} catch (e: Exception) {
logger.error("更新节点失败: ${e.message}", e)
@@ -295,6 +316,10 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
}
// 清理 RPC 缓存
logger.info("删除节点,清理 RPC 缓存: ${node.httpUrl}")
retrofitFactory.clearRpcApiCache(node.httpUrl)
rpcNodeConfigRepository.delete(node)
logger.info("成功删除 RPC 节点: ${node.name}")
Result.success(Unit)
@@ -84,5 +84,38 @@ object DateUtils {
}
return displayFormatter.format(instant)
}
/**
* 将时间间隔毫秒格式化为可读的字符串
* 格式X天X小时X分钟 X小时X分钟 X分钟
* 只显示有意义的单位不显示0值单位
*
* @param milliseconds 时间间隔毫秒
* @return 格式化的时间间隔字符串 "2天3小时15分钟""5小时30分钟""45分钟"
*/
fun formatDuration(milliseconds: Long): String {
if (milliseconds < 0) {
return "0分钟"
}
val totalSeconds = milliseconds / 1000
val days = totalSeconds / (24 * 60 * 60)
val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60)
val minutes = (totalSeconds % (60 * 60)) / 60
val parts = mutableListOf<String>()
if (days > 0) {
parts.add("${days}")
}
if (hours > 0) {
parts.add("${hours}小时")
}
if (minutes > 0 || parts.isEmpty()) {
parts.add("${minutes}分钟")
}
return parts.joinToString("")
}
}
@@ -7,6 +7,7 @@ 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 com.wrbug.polymarketbot.constants.PolymarketConstants
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
@@ -18,7 +19,6 @@ 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
@@ -34,10 +34,6 @@ import jakarta.annotation.PreDestroy
*/
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String,
private val gson: Gson
) {
@@ -58,10 +54,10 @@ class RetrofitFactory(
// 缓存 Gamma API 客户端(单例)
private val gammaApi: PolymarketGammaApi by lazy {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
val baseUrl = if (PolymarketConstants.GAMMA_BASE_URL.endsWith("/")) {
PolymarketConstants.GAMMA_BASE_URL.dropLast(1)
} else {
gammaBaseUrl
PolymarketConstants.GAMMA_BASE_URL
}
Retrofit.Builder()
@@ -74,7 +70,7 @@ class RetrofitFactory(
// 缓存 Data API 客户端(单例)
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = "https://data-api.polymarket.com"
val baseUrl = PolymarketConstants.DATA_API_BASE_URL
Retrofit.Builder()
.baseUrl("$baseUrl/")
@@ -113,7 +109,7 @@ class RetrofitFactory(
// 缓存不带认证的 CLOB API 客户端(单例)
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -158,7 +154,7 @@ class RetrofitFactory(
.build()
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@@ -1,7 +1,7 @@
package com.wrbug.polymarketbot.websocket
import com.wrbug.polymarketbot.constants.PolymarketConstants
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.socket.*
import java.util.concurrent.ConcurrentHashMap
@@ -15,8 +15,7 @@ class PolymarketWebSocketHandler : WebSocketHandler {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
@Value("\${polymarket.rtds.ws-url}")
private lateinit var polymarketWsUrl: String
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
// 存储客户端会话和对应的 Polymarket 连接的映射
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
@@ -35,18 +35,14 @@ logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
polymarket.websocket.url=wss://ws-live-data.polymarket.com
polymarket.websocket.activity.url=${POLYMARKET_WEBSOCKET_ACTIVITY_URL:wss://ws-live-data.polymarket.com}
polymarket.data-api.base-url=https://data-api.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
# 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/}
# 注意:Builder Relayer URL 现在使用代码常量(PolymarketConstants.BUILDER_RELAYER_URL),不再从配置文件读取
# 跟单轮询配置
# 轮询间隔(毫秒),默认2秒
@@ -0,0 +1,12 @@
-- ============================================
-- V25: 添加订单来源字段到跟单订单跟踪表
-- 用于记录订单是从哪个数据源接收到的(activity-ws 或 onchain-ws
-- ============================================
-- 添加订单来源字段
ALTER TABLE copy_order_tracking
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'unknown' COMMENT '订单来源:activity-wsPolymarket WebSocket)、onchain-wsOnChain WebSocket';
-- 对于已有数据,设置为默认值 unknown(不影响现有功能)
-- 新创建的记录会在创建时自动填充此字段
@@ -0,0 +1,8 @@
-- ============================================
-- V26: 移除最大仓位数量配置字段
-- 从 copy_trading 表中删除 max_position_count 字段
-- ============================================
ALTER TABLE copy_trading
DROP COLUMN max_position_count;
@@ -0,0 +1,97 @@
-- ============================================
-- 回测功能表创建
-- ============================================
-- ============================================
-- 2. 创建回测任务表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID',
task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金',
final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金',
profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额',
profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)',
backtest_days INT NOT NULL COMMENT '回测天数',
start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)',
end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)',
-- 跟单配置 (复制CopyTrading表结构)
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED',
copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例',
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单',
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单',
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损',
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数',
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)',
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数',
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出',
min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度',
max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差',
min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格',
max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格',
max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额',
keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
keywords JSON DEFAULT NULL COMMENT '关键字列表',
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
-- 统计字段
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
-- 执行状态
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED',
progress INT DEFAULT 0 COMMENT '执行进度(0-100)',
total_trades INT DEFAULT 0 COMMENT '总交易笔数',
buy_trades INT DEFAULT 0 COMMENT '买入笔数',
sell_trades INT DEFAULT 0 COMMENT '卖出笔数',
win_trades INT DEFAULT 0 COMMENT '盈利交易笔数',
loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数',
win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)',
max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利',
max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损',
max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤',
error_message TEXT DEFAULT NULL COMMENT '错误信息',
created_at BIGINT NOT NULL COMMENT '创建时间',
execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)',
execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_leader_id (leader_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_leader_profit (leader_id, profit_rate DESC),
INDEX idx_status_created (status, created_at DESC),
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表';
-- ============================================
-- 3. 创建回测交易记录表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_trade (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID',
backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID',
trade_time BIGINT NOT NULL COMMENT '交易时间',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT',
outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
price DECIMAL(20, 8) NOT NULL COMMENT '价格',
amount DECIMAL(20, 8) NOT NULL COMMENT '金额',
fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费',
profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)',
balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额',
leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_backtest_task_id (backtest_task_id),
INDEX idx_trade_time (trade_time),
FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表';
@@ -0,0 +1,14 @@
-- ============================================
-- 回测功能恢复字段添加
-- ============================================
-- 添加恢复相关字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
-- 添加索引以优化查询性能
ALTER TABLE backtest_task
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
@@ -0,0 +1,13 @@
-- Drop unused columns from backtest_task table
-- These fields are not needed for backtest scenarios as they use historical data
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
-- Check if columns exist before dropping (using standard approach)
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
ALTER TABLE backtest_task DROP COLUMN max_spread;
ALTER TABLE backtest_task DROP COLUMN min_price;
ALTER TABLE backtest_task DROP COLUMN max_price;
ALTER TABLE backtest_task DROP COLUMN max_position_value;
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
@@ -0,0 +1,15 @@
-- ============================================
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
-- ============================================
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
UPDATE backtest_task
SET last_processed_trade_index = NULL
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
ALTER TABLE backtest_task
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
@@ -0,0 +1,3 @@
-- 添加最大仓位金额配置到回测任务表
ALTER TABLE backtest_task
ADD COLUMN max_position_value DECIMAL(20, 8) COMMENT '最大仓位金额(USDC),NULL表示不启用';
@@ -0,0 +1,9 @@
-- ============================================
-- V32: 添加回测价格区间过滤字段
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
-- ============================================
-- 添加价格区间字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
@@ -0,0 +1,38 @@
-- ============================================
-- V33: 唯一约束从 wallet_address 改为 proxy_address
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
-- ============================================
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
UPDATE wallet_accounts SET wallet_type = 'safe';
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'wallet_accounts'
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
AND kcu.COLUMN_NAME = 'wallet_address'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_accounts'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
LIMIT 1);
SET @sql2 = IF(@uk_exists IS NULL,
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
'SELECT 1');
PREPARE stmt2 FROM @sql2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
@@ -255,3 +255,56 @@ error.server.order_tracking_process_failed=Failed to process order tracking
error.server.order_tracking_buy_failed=Failed to process buy order
error.server.order_tracking_sell_failed=Failed to process sell order
error.server.order_tracking_match_failed=Order matching failed
# Backtest service errors
error.backtest.task_not_found=Backtest task not found
error.backtest.leader_not_found=Leader not found
error.backtest.days_invalid=Backtest days must be between 1-15 days
error.backtest.initial_balance_invalid=Invalid initial balance
error.backtest.task_running=Backtest task is running, cannot delete
error.backtest.task_not_completed=Re-run is only supported for completed backtest tasks
error.server.backtest_create_failed=Failed to create backtest task
error.server.backtest_update_failed=Failed to update backtest task
error.server.backtest_delete_failed=Failed to delete backtest task
error.server.backtest_list_fetch_failed=Failed to fetch backtest list
error.server.backtest_detail_fetch_failed=Failed to fetch backtest detail
error.server.backtest_trades_fetch_failed=Failed to fetch backtest trades
error.server.backtest_execute_failed=Failed to execute backtest
error.server.backtest_historical_data_fetch_failed=Failed to fetch historical data
error.server.backtest_stop_failed=Failed to stop backtest task
error.server.backtest_retry_failed=Failed to retry backtest task
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
# Backtest Management
backtest.title=Backtest Management
backtest.create_task=Create Backtest
backtest.task_name=Task Name
backtest.leader=Leader
backtest.initial_balance=Initial Balance
backtest.backtest_days=Backtest Days
backtest.profit_amount=Profit Amount
backtest.profit_rate=Profit Rate
backtest.backtest_days_range=Backtest Days Range (1-15 days)
backtest.total_trades=Total Trades
backtest.buy_trades=Buy Trades
backtest.sell_trades=Sell Trades
backtest.win_trades=Win Trades
backtest.loss_trades=Loss Trades
backtest.win_rate=Win Rate
backtest.max_profit=Max Profit
backtest.max_loss=Max Loss
backtest.max_drawdown=Max Drawdown
backtest.avg_holding_time=Avg Holding Time
# Backtest Status
backtest.status.pending=Pending
backtest.status.running=Running
backtest.status.completed=Completed
backtest.status.stopped=Stopped
backtest.status.failed=Failed
# Backtest Config
backtest.copy_mode.ratio=Ratio Mode
backtest.copy_mode.fixed=Fixed Amount
backtest.price_tolerance=Price Tolerance
backtest.delay_seconds=Delay Seconds
backtest.support_sell=Support Sell
@@ -255,3 +255,62 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
# 回测服务错误
error.backtest.task_not_found=回测任务不存在
error.backtest.leader_not_found=Leader不存在
error.backtest.days_invalid=回测天数必须在 1-15 天之间
error.backtest.initial_balance_invalid=初始金额无效
error.backtest.task_running=回测任务正在运行,无法删除
error.backtest.task_not_completed=仅支持对已完成的回测任务重新测试
error.server.backtest_create_failed=创建回测任务失败
error.server.backtest_update_failed=更新回测任务失败
error.server.backtest_delete_failed=删除回测任务失败
error.server.backtest_list_fetch_failed=查询回测列表失败
error.server.backtest_detail_fetch_failed=查询回测详情失败
error.server.backtest_trades_fetch_failed=查询回测交易记录失败
error.server.backtest_execute_failed=回测执行失败
error.server.backtest_historical_data_fetch_failed=历史数据获取失败
error.server.backtest_stop_failed=停止回测任务失败
error.server.backtest_retry_failed=重试回测任务失败
error.server.backtest_rerun_failed=按配置重新测试失败
# 回测管理
backtest.title=回测管理
backtest.create_task=新增回测
backtest.task_name=回测名称
backtest.leader=Leader
backtest.initial_balance=初始金额
backtest.backtest_days=回测天数
backtest.profit_amount=收益额
backtest.profit_rate=收益率
backtest.backtest_days_range=回测天数范围(1-15天)
backtest.total_trades=总交易笔数
backtest.buy_trades=买入笔数
backtest.sell_trades=卖出笔数
backtest.win_trades=盈利交易笔数
backtest.loss_trades=亏损交易笔数
backtest.win_rate=胜率
backtest.max_profit=最大单笔盈利
backtest.max_loss=最大单笔亏损
backtest.max_drawdown=最大回撤
backtest.avg_holding_time=平均持仓时间
# 回测状态
backtest.status.pending=待执行
backtest.status.running=运行中
backtest.status.completed=已完成
backtest.status.stopped=已停止
backtest.status.failed=失败
# 回测配置
backtest.copy_mode.ratio=比例模式
backtest.copy_mode.fixed=固定金额
backtest.price_tolerance=价格容忍度
backtest.delay_seconds=延迟秒数
backtest.support_sell=支持卖出
# 订单跟踪服务错误
error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
@@ -255,3 +255,56 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗
error.server.order_tracking_buy_failed=處理買入訂單失敗
error.server.order_tracking_sell_failed=處理賣出訂單失敗
error.server.order_tracking_match_failed=訂單匹配失敗
# 回測服務錯誤
error.backtest.task_not_found=回測任務不存在
error.backtest.leader_not_found=Leader不存在
error.backtest.days_invalid=回測天數必須在 1-15 天之間
error.backtest.initial_balance_invalid=初始金額無效
error.backtest.task_running=回測任務正在運行,無法刪除
error.backtest.task_not_completed=僅支援對已完成的回測任務重新測試
error.server.backtest_create_failed=創建回測任務失敗
error.server.backtest_update_failed=更新回測任務失敗
error.server.backtest_delete_failed=刪除回測任務失敗
error.server.backtest_list_fetch_failed=查詢回測列表失敗
error.server.backtest_detail_fetch_failed=查詢回測詳情失敗
error.server.backtest_trades_fetch_failed=查詢回測交易記錄失敗
error.server.backtest_execute_failed=回測執行失敗
error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
error.server.backtest_stop_failed=停止回測任務失敗
error.server.backtest_retry_failed=重試回測任務失敗
error.server.backtest_rerun_failed=依配置重新測試失敗
# 回測管理
backtest.title=回測管理
backtest.create_task=新增回測
backtest.task_name=回測名稱
backtest.leader=Leader
backtest.initial_balance=初始金額
backtest.backtest_days=回測天數
backtest.profit_amount=收益額
backtest.profit_rate=收益率
backtest.backtest_days_range=回測天數範圍(1-15天)
backtest.total_trades=總交易筆數
backtest.buy_trades=買入筆數
backtest.sell_trades=賣出筆數
backtest.win_trades=盈利交易筆數
backtest.loss_trades=虧損交易筆數
backtest.win_rate=勝率
backtest.max_profit=最大單筆盈利
backtest.max_loss=最大單筆虧損
backtest.max_drawdown=最大回撤
backtest.avg_holding_time=平均持倉時間
# 回測狀態
backtest.status.pending=待執行
backtest.status.running=運行中
backtest.status.completed=已完成
backtest.status.stopped=已停止
backtest.status.failed=失敗
# 回測配置
backtest.copy_mode.ratio=比例模式
backtest.copy_mode.fixed=固定金額
backtest.price_tolerance=價格容忍度
backtest.delay_seconds=延遲秒數
backtest.support_sell=支持賣出
+349
View File
@@ -0,0 +1,349 @@
#!/bin/bash
# PolyHermes Release 创建脚本
# 功能:创建 tag、推送 tag、创建 GitHub Release(支持 pre-release
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 打印信息
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 显示使用说明
usage() {
cat << EOF
用法: $0 [选项]
选项:
-t, --tag TAG 版本号 tag(必需,格式:v1.0.0)
-T, --title TITLE Release 标题(可选,默认使用 tag)
-d, --description DESC Release 描述内容(可选)
-f, --description-file FILE 从文件读取 Release 描述(可选)
-p, --prerelease 标记为 Pre-release(会自动拼接 -beta 后缀,默认:false
-y, --yes 无交互模式,自动确认所有操作(默认:false)
-h, --help 显示此帮助信息
示例:
# 创建正式版本
$0 -t v1.0.1 -T "Release v1.0.1" -d "## 新功能\n- 功能1\n- 功能2"
# 创建 Pre-release(自动拼接 -beta
$0 -t v1.0.1 -T "Release v1.0.1-beta" -d "测试版本" --prerelease
# 实际创建的 tag: v1.0.1-beta
# 从文件读取描述
$0 -t v1.0.1 -f CHANGELOG.md --prerelease
# 实际创建的 tag: v1.0.1-beta
# 无交互模式(适合 CI/CD 或自动化脚本)
$0 -t v1.0.1 -d "更新内容" --yes
版本号格式:
- 必须格式: v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)
- 如果指定 --prerelease,会自动拼接 -beta 后缀 (例如: v1.0.1 -> v1.0.1-beta)
EOF
}
# 验证版本号格式(只允许 v数字.数字.数字,不允许后缀)
validate_tag() {
local tag=$1
# 匹配格式:v数字.数字.数字(不允许后缀)
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
error "版本号格式不正确:$tag"
error "应为 v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)"
error "如果创建 Pre-release,请使用 --prerelease 参数,脚本会自动拼接 -beta 后缀"
exit 1
fi
return 0
}
# 检查必要的工具
check_requirements() {
local auto_yes=$1
# 检查 git
if ! command -v git &> /dev/null; then
error "未找到 git 命令,请先安装 git"
exit 1
fi
# 检查 GitHub CLI
if ! command -v gh &> /dev/null; then
error "未找到 GitHub CLI (gh) 命令"
error "请先安装 GitHub CLI: https://cli.github.com/"
exit 1
fi
# 检查是否已登录 GitHub
if ! gh auth status &> /dev/null; then
error "未登录 GitHub,请先运行: gh auth login"
exit 1
fi
# 检查是否有未提交的更改
if [[ -n $(git status --porcelain) ]]; then
warn "检测到未提交的更改,建议先提交或暂存"
if [[ "$auto_yes" == "true" ]]; then
info "无交互模式:自动继续"
else
read -p "是否继续?(y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "已取消"
exit 0
fi
fi
fi
# 检查是否在正确的分支
local current_branch=$(git branch --show-current)
info "当前分支: $current_branch"
}
# 检查 tag 是否已存在
check_tag_exists() {
local tag=$1
local auto_yes=$2
if git rev-parse "$tag" >/dev/null 2>&1; then
error "Tag $tag 已存在(本地)"
if [[ "$auto_yes" == "true" ]]; then
info "无交互模式:自动删除并重新创建"
git tag -d "$tag" || true
git push origin ":refs/tags/$tag" || true
info "已删除旧 tag: $tag"
else
read -p "是否删除并重新创建?(y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
git tag -d "$tag" || true
git push origin ":refs/tags/$tag" || true
info "已删除旧 tag: $tag"
else
error "已取消"
exit 1
fi
fi
fi
# 检查远程是否存在
if git ls-remote --tags origin "$tag" | grep -q "$tag"; then
error "Tag $tag 已存在于远程仓库"
if [[ "$auto_yes" == "true" ]]; then
info "无交互模式:自动删除并重新创建"
git tag -d "$tag" || true
git push origin ":refs/tags/$tag" || true
info "已删除远程 tag: $tag"
else
read -p "是否删除并重新创建?(y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
git tag -d "$tag" || true
git push origin ":refs/tags/$tag" || true
info "已删除远程 tag: $tag"
else
error "已取消"
exit 1
fi
fi
fi
}
# 主函数
main() {
local TAG=""
local TITLE=""
local DESCRIPTION=""
local DESCRIPTION_FILE=""
local PRERELEASE=false
local AUTO_YES=false
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
TAG="$2"
shift 2
;;
-T|--title)
TITLE="$2"
shift 2
;;
-d|--description)
DESCRIPTION="$2"
shift 2
;;
-f|--description-file)
DESCRIPTION_FILE="$2"
shift 2
;;
-p|--prerelease)
PRERELEASE=true
shift
;;
-y|--yes)
AUTO_YES=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
error "未知参数: $1"
usage
exit 1
;;
esac
done
# 检查必需参数
if [[ -z "$TAG" ]]; then
error "缺少必需参数: --tag"
usage
exit 1
fi
# 验证版本号格式(不允许后缀)
validate_tag "$TAG"
# 如果指定了 --prerelease,自动拼接 -beta 后缀
local BASE_TAG="$TAG"
if [[ "$PRERELEASE" == "true" ]]; then
TAG="${BASE_TAG}-beta"
info "Pre-release 模式:tag 将自动拼接 -beta 后缀"
info "基础版本: $BASE_TAG -> 实际 tag: $TAG"
fi
# 检查工具和环境
check_requirements "$AUTO_YES"
# 检查 tag 是否已存在(使用拼接后的 tag)
check_tag_exists "$TAG" "$AUTO_YES"
# 设置默认标题
if [[ -z "$TITLE" ]]; then
TITLE="$TAG"
fi
# 读取描述内容
if [[ -n "$DESCRIPTION_FILE" ]]; then
if [[ ! -f "$DESCRIPTION_FILE" ]]; then
error "描述文件不存在: $DESCRIPTION_FILE"
exit 1
fi
DESCRIPTION=$(cat "$DESCRIPTION_FILE")
fi
# 如果没有描述,使用默认值
if [[ -z "$DESCRIPTION" ]]; then
if [[ "$PRERELEASE" == "true" ]]; then
DESCRIPTION="Pre-release $TAG"
else
DESCRIPTION="Release $TAG"
fi
fi
# 显示即将执行的操作
echo
info "========================================="
info " PolyHermes Release 创建"
info "========================================="
info "Tag: $TAG"
info "Title: $TITLE"
info "Pre-release: $PRERELEASE"
if [[ "$AUTO_YES" == "true" ]]; then
info "模式: 无交互模式(自动确认)"
fi
info "Description:"
echo "$DESCRIPTION" | sed 's/^/ /'
info "========================================="
echo
# 确认操作
if [[ "$AUTO_YES" == "true" ]]; then
info "无交互模式:自动确认创建 Release"
else
read -p "确认创建 Release(y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "已取消"
exit 0
fi
fi
# 1. 创建 tag(基于当前 HEAD
info "创建 tag: $TAG"
git tag "$TAG"
success "Tag 创建成功: $TAG"
# 2. 推送 tag 到远程
info "推送 tag 到远程..."
git push origin "$TAG"
success "Tag 推送成功"
# 3. 创建 GitHub Release
info "创建 GitHub Release..."
local RELEASE_ARGS=(
"$TAG"
--title "$TITLE"
--notes "$DESCRIPTION"
)
if [[ "$PRERELEASE" == "true" ]]; then
RELEASE_ARGS+=(--prerelease)
fi
if gh release create "${RELEASE_ARGS[@]}"; then
success "GitHub Release 创建成功!"
# 获取 release URL
local RELEASE_URL=$(gh release view "$TAG" --json url -q .url)
info "Release URL: $RELEASE_URL"
echo
success "========================================="
success " Release 创建完成!"
success "========================================="
success "Tag: $TAG"
success "Pre-release: $PRERELEASE"
success "URL: $RELEASE_URL"
success "========================================="
echo
info "GitHub Actions 将自动触发构建流程"
if [[ "$PRERELEASE" == "true" ]]; then
warn "这是 Pre-releaseGitHub Actions 不会发送 Telegram 通知"
fi
else
error "GitHub Release 创建失败"
error "请手动在 GitHub 上创建 Release: https://github.com/WrBug/PolyHermes/releases/new"
exit 1
fi
}
# 执行主函数
main "$@"
+194
View File
@@ -0,0 +1,194 @@
# PolyHermes 一键部署脚本使用说明
## ✨ 核心特性
- **可在任意目录运行** - 无需下载项目源码
- **仅使用线上镜像** - 从 Docker Hub 拉取官方镜像
- **自动下载配置** - 从 GitHub 下载最新的 `docker-compose.prod.yml`
- **交互式配置** - 友好的问答式配置向导
- **自动生成密钥** - 所有敏感配置回车自动生成安全随机值
## 🚀 快速开始
### 一键安装(推荐)
**使用 curl(推荐):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**使用 wget**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
这个命令会自动:
- 📁 创建专用工作目录 `~/polyhermes`
- 📥 下载部署脚本
- ✅ 自动检查 Docker 环境
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
- 🔐 自动生成安全的随机密钥
- 🚀 自动下载最新镜像并部署
**或者直接通过管道运行(不保存文件):**
```bash
# curl 方式
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
# wget 方式
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
```
### 方式一:直接下载脚本运行
```bash
# 下载脚本
curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh
# 添加执行权限
chmod +x deploy-interactive.sh
# 运行
./deploy-interactive.sh
```
### 方式二:在项目目录中运行
```bash
git clone https://github.com/WrBug/PolyHermes.git
cd PolyHermes
./deploy-interactive.sh
```
## 📝 使用流程
运行脚本后,会引导你完成以下步骤:
```
步骤 1: 环境检查 → 检查 Docker/Docker Compose
步骤 2: 配置收集 → 交互式输入配置(可全部回车使用默认)
步骤 3: 获取部署配置 → 从 GitHub 下载 docker-compose.prod.yml
步骤 4: 生成环境变量文件 → 自动生成 .env
步骤 5: 拉取 Docker 镜像 → 从 Docker Hub 拉取最新镜像
步骤 6: 部署服务 → 启动容器
步骤 7: 健康检查 → 验证服务是否正常运行
```
## ⚡ 最简单的使用方式
**所有配置项直接回车使用默认值**,脚本会自动:
- 使用端口 80(应用)和 3307MySQL
- 生成 32 字符的数据库密码
- 生成 128 字符的 JWT 密钥
- 生成 64 字符的管理员重置密钥
- 生成 64 字符的加密密钥
- 配置合理的日志级别
### 交互示例
脚本会逐项提示你输入配置,**直接按回车即可跳过使用默认值**:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤 2: 配置收集
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成
⚠ 密钥配置:回车将自动生成安全的随机密钥
⚠ 其他配置:回车将使用括号中的默认值
【基础配置】
将配置:服务器端口、MySQL端口、时区
➤ 服务器端口 [默认: 80]: ⏎
➤ MySQL 端口(外部访问) [默认: 3307]: ⏎
➤ 时区 [默认: Asia/Shanghai]: ⏎
【数据库配置】
将配置:数据库用户名、数据库密码
➤ 数据库用户名 [默认: root]: ⏎
➤ 数据库密码 [回车自动生成]: ⏎
[✓] 已自动生成数据库密码(32字符)
【安全配置】
将配置:JWT密钥、管理员密码重置密钥、数据加密密钥
➤ JWT 密钥 [回车自动生成]: ⏎
[✓] 已自动生成 JWT 密钥(128字符)
➤ 管理员密码重置密钥 [回车自动生成]: ⏎
[✓] 已自动生成管理员重置密钥(64字符)
➤ 加密密钥(用于加密 API Key) [回车自动生成]: ⏎
[✓] 已自动生成加密密钥(64字符)
【日志配置】
将配置:Root日志级别、应用日志级别
可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF
➤ Root 日志级别(第三方库) [默认: WARN]: ⏎
➤ 应用日志级别 [默认: INFO]: ⏎
【其他配置】
将配置:运行环境、自动更新策略、GitHub仓库
➤ Spring Profile [默认: prod]: ⏎
➤ 允许预发布版本更新 (true/false) [默认: false]: ⏎
➤ GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
```
## 🔧 脚本生成的文件
脚本运行后会在当前目录生成:
1. **docker-compose.prod.yml** - 从 GitHub 下载的 Docker Compose 配置文件(始终保持最新)
2. **.env** - 根据你的配置自动生成的环境变量文件
这两个文件包含了运行 PolyHermes 所需的全部配置。
## 🌐 部署后管理
### 快速更新(推荐)
如果已有配置文件,再次运行脚本时会自动检测并询问:
```bash
./deploy-interactive.sh
```
```
【检测到现有配置】
发现已存在的 .env 配置文件
是否使用现有配置直接更新镜像?[Y/n]: ⏎
```
- **回车或输入 Y**:使用现有配置,直接拉取最新镜像并更新
- **输入 N**:重新配置(会备份现有配置)
### 手动管理命令
```bash
# 查看服务状态
docker compose -f docker-compose.prod.yml ps
# 查看日志
docker compose -f docker-compose.prod.yml logs -f
# 重启服务
docker compose -f docker-compose.prod.yml restart
# 停止服务
docker compose -f docker-compose.prod.yml down
# 更新到最新版本
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
```
## 🔐 安全建议
- **保护 .env 文件**:其中包含敏感信息,切勿提交到版本控制
- **定期备份数据库**:数据存储在 Docker volume `mysql-data`
- **生产环境配置 HTTPS**:建议使用 Nginx 或 Caddy 作为反向代理
## 📞 获取支持
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
- [完整部署文档](docs/zh/DEPLOYMENT_GUIDE.md)
+510
View File
@@ -0,0 +1,510 @@
#!/bin/bash
# ========================================
# PolyHermes 交互式一键部署脚本
# ========================================
# 功能:
# - 交互式配置环境变量
# - 自动生成安全密钥
# - 使用 Docker Hub 线上镜像部署
# - 支持配置预检和回滚
# ========================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印函数
info() {
echo -e "${GREEN}[✓]${NC} $1"
}
warn() {
echo -e "${YELLOW}[⚠]${NC} $1"
}
error() {
echo -e "${RED}[✗]${NC} $1"
}
title() {
echo -e "${CYAN}${1}${NC}"
}
# 生成随机密钥
generate_secret() {
local length=${1:-32}
if command -v openssl &> /dev/null; then
openssl rand -hex $length
else
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $((length * 2)) | head -n 1
fi
}
# 生成随机端口号(10000-60000之间)
generate_random_port() {
echo $((10000 + RANDOM % 50001))
}
# 读取用户输入(支持默认值)
read_input() {
local prompt="$1"
local default="$2"
local is_secret="$3"
local value=""
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
local prompt_text=""
if [ -n "$default" ]; then
if [ "$is_secret" = "secret" ]; then
prompt_text="${prompt} [回车自动生成]: "
else
prompt_text="${prompt} [默认: ${default}]: "
fi
else
prompt_text="${prompt}: "
fi
# 使用 read -p 确保提示正确显示
read -r -p "$prompt_text" value
# 如果用户没有输入,使用默认值
if [ -z "$value" ]; then
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
# 自动生成密钥
case "$prompt" in
*JWT*)
value=$(generate_secret 64)
# 输出到 stderr,避免被捕获到返回值中
info "已自动生成 JWT 密钥(128字符)" >&2
;;
*管理员*|*ADMIN*)
value=$(generate_secret 32)
info "已自动生成管理员重置密钥(64字符)" >&2
;;
*加密*|*CRYPTO*)
value=$(generate_secret 32)
info "已自动生成加密密钥(64字符)" >&2
;;
*数据库密码*|*DB_PASSWORD*)
value=$(generate_secret 16)
info "已自动生成数据库密码(32字符)" >&2
;;
*)
value="$default"
;;
esac
else
value="$default"
fi
fi
echo "$value"
}
# 检查 Docker 环境
check_docker() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 1: 环境检查"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 检查 Docker
if ! command -v docker &> /dev/null; then
error "Docker 未安装"
echo ""
info "请先安装 Docker"
info " macOS: brew install docker"
info " Ubuntu/Debian: apt-get install docker.io"
info " CentOS/RHEL: yum install docker"
exit 1
fi
info "Docker 已安装: $(docker --version | head -1)"
# 检查 Docker Compose
if docker compose version &> /dev/null 2>&1; then
info "Docker Compose 已安装: $(docker compose version)"
elif command -v docker-compose &> /dev/null; then
info "Docker Compose 已安装: $(docker-compose --version)"
else
error "Docker Compose 未安装"
echo ""
info "请先安装 Docker Compose"
info " https://docs.docker.com/compose/install/"
exit 1
fi
# 检查 Docker 守护进程
if ! docker info &> /dev/null; then
error "Docker 守护进程未运行"
info "请启动 Docker 服务:"
info " macOS: 打开 Docker Desktop"
info " Linux: systemctl start docker"
exit 1
fi
info "Docker 守护进程运行正常"
echo ""
}
# 交互式配置收集
collect_configuration() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 2: 配置收集"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
echo ""
warn "密钥配置:回车将自动生成安全的随机密钥"
warn "其他配置:回车将使用括号中的默认值"
echo ""
# 基础配置
title "【基础配置】"
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
# 生成随机端口作为默认值
DEFAULT_PORT=$(generate_random_port)
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
echo ""
# 数据库配置
title "【数据库配置】"
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
echo ""
# 安全配置
title "【安全配置】"
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key" "" "secret")
echo ""
# 日志配置
title "【日志配置】"
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
echo ""
# 自动设置不需要用户输入的配置
SPRING_PROFILES_ACTIVE="prod"
ALLOW_PRERELEASE="false"
GITHUB_REPO="WrBug/PolyHermes"
}
# 下载 docker-compose.prod.yml(如果不存在)
download_docker_compose_file() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 3: 获取部署配置"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ -f "docker-compose.prod.yml" ]; then
info "检测到现有 docker-compose.prod.yml,跳过下载"
echo ""
return 0
fi
info "正在从 GitHub 下载 docker-compose.prod.yml..."
# GitHub raw 文件链接
local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml"
# 尝试下载
if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then
info "docker-compose.prod.yml 下载成功"
else
error "docker-compose.prod.yml 下载失败"
warn "请检查网络连接或手动下载:"
warn " $compose_url"
exit 1
fi
echo ""
}
# 生成 .env 文件
generate_env_file() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 4: 生成环境变量文件"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 备份现有 .env 文件
if [ -f ".env" ]; then
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
cp .env "$BACKUP_FILE"
warn "已备份现有配置文件到: $BACKUP_FILE"
fi
# 生成新的 .env 文件
cat > .env <<EOF
# ========================================
# PolyHermes 生产环境配置
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# ========================================
# ============================================
# 基础配置
# ============================================
TZ=${TZ}
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
SERVER_PORT=${SERVER_PORT}
MYSQL_PORT=${MYSQL_PORT}
# ============================================
# 数据库配置
# ============================================
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
DB_USERNAME=${DB_USERNAME}
DB_PASSWORD=${DB_PASSWORD}
# ============================================
# 安全配置(请妥善保管)
# ============================================
JWT_SECRET=${JWT_SECRET}
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
# ============================================
# 日志配置
# ============================================
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
LOG_LEVEL_APP=${LOG_LEVEL_APP}
# ============================================
# 其他配置
# ============================================
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
GITHUB_REPO=${GITHUB_REPO}
EOF
info "配置文件已生成: .env"
echo ""
# 显示配置摘要
title "【配置摘要】"
echo " 服务器端口: ${SERVER_PORT}"
echo " MySQL 端口: ${MYSQL_PORT}"
echo " 时区: ${TZ}"
echo " 数据库用户: ${DB_USERNAME}"
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
echo ""
}
# 拉取镜像
pull_images() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 5: 拉取 Docker 镜像"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "正在从 Docker Hub 拉取最新镜像..."
# 拉取应用镜像
if docker pull wrbug/polyhermes:latest; then
info "应用镜像拉取成功: wrbug/polyhermes:latest"
else
error "应用镜像拉取失败"
warn "可能的原因:"
warn " 1. 网络连接问题"
warn " 2. Docker Hub 服务异常"
warn " 3. 镜像不存在"
exit 1
fi
# 拉取 MySQL 镜像
if docker pull mysql:8.2; then
info "MySQL 镜像拉取成功: mysql:8.2"
else
warn "MySQL 镜像拉取失败,将在启动时自动下载"
fi
echo ""
}
# 部署服务
deploy_services() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 6: 部署服务"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 停止现有服务
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
warn "检测到正在运行的服务,正在停止..."
docker compose -f docker-compose.prod.yml down
info "已停止现有服务"
fi
# 启动服务
info "正在启动服务..."
if docker compose -f docker-compose.prod.yml up -d; then
info "服务启动成功"
else
error "服务启动失败"
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
exit 1
fi
echo ""
}
# 健康检查
health_check() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 7: 健康检查"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "等待服务启动(最多等待 60 秒)..."
local max_attempts=12
local attempt=0
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# 检查容器状态
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
info "容器运行正常"
# 检查应用是否响应
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then
info "应用响应正常"
echo ""
return 0
fi
fi
echo -n "."
sleep 5
done
echo ""
warn "健康检查超时,请手动检查服务状态"
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
echo ""
}
# 显示部署信息
show_deployment_info() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 部署完成!"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
echo ""
title "【常用命令】"
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
echo ""
title "【数据库连接信息】"
echo -e " 主机: ${CYAN}localhost${NC}"
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
echo -e " 数据库: ${CYAN}polyhermes${NC}"
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
echo ""
title "【管理员重置密钥】"
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
echo ""
warn "重要提示:"
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data"
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
echo ""
}
# 主函数
main() {
clear
echo ""
title "========================================="
title " PolyHermes 交互式一键部署脚本 "
title "========================================="
echo ""
# 执行部署流程
check_docker
# 检查是否已存在 .env 文件
if [ -f ".env" ]; then
echo ""
title "【检测到现有配置】"
info "发现已存在的 .env 配置文件"
echo ""
echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}"
read -r use_existing
use_existing=${use_existing:-Y}
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
info "将使用现有配置,跳过配置步骤"
echo ""
# 从现有 .env 文件读取必要的变量
source .env 2>/dev/null || true
else
warn "将重新配置,现有配置将被备份"
echo ""
collect_configuration
fi
else
collect_configuration
fi
download_docker_compose_file
# 只有在重新配置时才生成新的 .env 文件
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
generate_env_file
fi
# 确认部署
echo ""
title "【确认部署】"
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
read -r confirm
# 默认为 Y,只有明确输入 n/N 才取消
confirm=${confirm:-Y}
if [[ "$confirm" =~ ^[Nn]$ ]]; then
warn "部署已取消"
exit 0
fi
echo ""
pull_images
deploy_services
health_check
show_deployment_info
info "部署流程已完成!"
}
# 捕获 Ctrl+C
trap 'echo ""; warn "部署已中断"; exit 1' INT
# 运行主函数
main "$@"
+4
View File
@@ -168,6 +168,10 @@ deploy() {
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION}..."
# 创建占位符目录(如果不存在),避免 Dockerfile COPY 失败
# 当 BUILD_IN_DOCKER=true 时,backend/build 可能不存在
mkdir -p backend/build/libs
# 设置构建参数(通过环境变量传递给 docker-compose.yml
export VERSION=${DOCKER_VERSION}
export GIT_TAG=${DOCKER_VERSION}
+3
View File
@@ -34,6 +34,9 @@ services:
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
# 动态更新配置
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
+61
View File
@@ -0,0 +1,61 @@
version: '3.8'
services:
app:
# 使用测试镜像(pre-release
image: wrbug/polyhermes:test
container_name: polyhermes-test
ports:
- "${SERVER_PORT:-8080}:80" # 使用不同端口避免冲突
environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=test # 使用测试 profile
- DB_URL=${DB_URL:-jdbc:mysql://mysql-test:3306/polyhermes_test?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
- JWT_SECRET=${JWT_SECRET:-test-jwt-secret-key-for-testing-only}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-test-reset-key-for-testing-only}
- LOG_LEVEL_ROOT=DEBUG
- LOG_LEVEL_APP=DEBUG
# 【测试环境】允许检测 pre-release 版本
- ALLOW_PRERELEASE=true
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
mysql-test:
condition: service_healthy
restart: unless-stopped
networks:
- polyhermes-test-network
mysql-test:
image: mysql:8.2
container_name: polyhermes-mysql-test
ports:
- "${MYSQL_PORT:-3308}:3306" # 使用不同端口
environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-testpassword}
- MYSQL_DATABASE=polyhermes_test
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-test-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-testpassword}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- polyhermes-test-network
volumes:
mysql-test-data:
networks:
polyhermes-test-network:
driver: bridge
+3
View File
@@ -32,6 +32,9 @@ services:
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
# 动态更新配置
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
+19
View File
@@ -54,6 +54,25 @@ http {
proxy_set_header Connection "upgrade";
}
# 【新增】更新服务 API(直接代理到 Python
location /api/update/ {
# 代理到更新服务(端口 9090
proxy_pass http://localhost:9090/;
# 传递请求头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 传递认证头(用于权限验证)
proxy_set_header Authorization $http_authorization;
# 超时设置(更新操作可能需要较长时间)
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 300s;
}
# WebSocket 代理
location /ws {
proxy_pass http://backend;
+33 -11
View File
@@ -1,9 +1,13 @@
#!/bin/bash
# 启动脚本:同时启动 Nginx 和后端服务
# 启动脚本:启动更新服务、后端服务和 Nginx
set -e
echo "========================================="
echo " PolyHermes 容器启动"
echo "========================================="
# 默认值常量
DEFAULT_JWT_SECRET="change-me-in-production"
DEFAULT_ADMIN_RESET_KEY="change-me-in-production"
@@ -42,6 +46,9 @@ check_security_config
# 函数:清理进程
cleanup() {
echo "收到退出信号,清理进程..."
if [ -n "$UPDATE_SERVICE_PID" ]; then
kill $UPDATE_SERVICE_PID 2>/dev/null || true
fi
if [ -n "$BACKEND_PID" ]; then
kill $BACKEND_PID 2>/dev/null || true
fi
@@ -52,27 +59,42 @@ cleanup() {
# 注册信号处理
trap cleanup SIGTERM SIGINT
# 启动后端服务(以 appuser 用户运行,后台运行
echo "启动后端服务..."
# 自动使用系统时区
# 1. 启动更新服务(后台运行,端口 9090
echo "🚀 启动更新服务..."
python3 /app/update-service.py &
UPDATE_SERVICE_PID=$!
echo "✅ 更新服务已启动 (PID: $UPDATE_SERVICE_PID, Port: 9090)"
# 等待更新服务就绪
sleep 2
# 2. 启动后端服务(后台运行,端口 8000)
echo "🚀 启动后端服务..."
java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} &
BACKEND_PID=$!
echo "✅ 后端服务已启动 (PID: $BACKEND_PID, Port: 8000)"
# 等待后端服务启动
echo "等待后端服务启动..."
# 3. 等待后端服务启动
echo "等待后端服务就绪..."
for i in {1..60}; do
if curl -f http://localhost:8000/api/system/health > /dev/null 2>&1; then
echo "后端服务已启动"
echo "后端服务健康检查通过"
break
fi
if [ $i -eq 60 ]; then
echo "后端服务启动超时"
echo "后端服务启动超时"
exit 1
fi
sleep 1
done
# 启动 Nginx(前台运行,作为主进程
echo "启动 Nginx..."
exec nginx -g "daemon off;"
# 4. 启动 Nginx(前台运行,保持容器存活
echo "🚀 启动 Nginx..."
echo "========================================="
echo " 容器启动完成"
echo " - 更新服务: http://localhost:9090"
echo " - 后端服务: http://localhost:8000"
echo " - 前端服务: http://localhost:80"
echo "========================================="
exec nginx -g "daemon off;"
+649
View File
@@ -0,0 +1,649 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PolyHermes 动态更新服务
负责检查更新下载更新包执行更新和回滚
"""
import os
import json
import logging
import subprocess
import time
import shutil
import tarfile
import requests
from pathlib import Path
from threading import Thread
from flask import Flask, jsonify, request
from datetime import datetime
# ==================== 配置 ====================
app = Flask(__name__)
# 日志配置
LOG_FILE = Path('/var/log/polyhermes/update-service.log')
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# 路径配置
APP_DIR = Path('/app')
VERSION_FILE = APP_DIR / 'version.json'
UPDATES_DIR = APP_DIR / 'updates'
BACKUPS_DIR = APP_DIR / 'backups'
BACKEND_JAR = APP_DIR / 'app.jar'
FRONTEND_DIR = Path('/usr/share/nginx/html')
# 创建必要目录
UPDATES_DIR.mkdir(parents=True, exist_ok=True)
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
# GitHub 配置
GITHUB_REPO = os.getenv('GITHUB_REPO', 'WrBug/PolyHermes')
ALLOW_PRERELEASE = os.getenv('ALLOW_PRERELEASE', 'false').lower() == 'true'
BACKEND_URL = 'http://localhost:8000'
# 更新状态
update_status = {
'updating': False,
'progress': 0,
'message': '就绪',
'error': None
}
# ==================== 工具函数 ====================
def get_current_version():
"""获取当前版本"""
try:
if VERSION_FILE.exists():
with open(VERSION_FILE) as f:
data = json.load(f)
return data.get('version', 'unknown')
return 'unknown'
except Exception as e:
logger.error(f"读取版本失败: {e}")
return 'unknown'
def fetch_latest_release():
"""获取最新 Release"""
try:
if ALLOW_PRERELEASE:
# 测试模式:获取所有 Release(包括 pre-release
url = f'https://api.github.com/repos/{GITHUB_REPO}/releases'
response = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=10)
releases = response.json()
if releases and len(releases) > 0:
latest = releases[0]
logger.info(f"检测到版本: {latest['tag_name']} (pre-release: {latest.get('prerelease', False)})")
return {
'tag': latest['tag_name'],
'name': latest['name'],
'body': latest['body'],
'published_at': latest['published_at'],
'assets': latest['assets'],
'prerelease': latest.get('prerelease', False)
}
else:
# 生产模式:只获取正式版本
url = f'https://api.github.com/repos/{GITHUB_REPO}/releases/latest'
response = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=10)
if response.status_code == 200:
data = response.json()
return {
'tag': data['tag_name'],
'name': data['name'],
'body': data['body'],
'published_at': data['published_at'],
'assets': data['assets'],
'prerelease': False
}
return None
except Exception as e:
logger.error(f"获取 Release 失败: {e}")
return None
def compare_versions(v1, v2):
"""
比较两个版本号语义化版本
返回: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
"""
def normalize(v):
parts = v.replace('v', '').split('-')[0].split('.')
return [int(x) for x in parts]
try:
parts1 = normalize(v1)
parts2 = normalize(v2)
for i in range(max(len(parts1), len(parts2))):
p1 = parts1[i] if i < len(parts1) else 0
p2 = parts2[i] if i < len(parts2) else 0
if p1 > p2:
return 1
elif p1 < p2:
return -1
return 0
except:
return 0
def check_admin_permission(req):
"""检查管理员权限"""
auth_header = req.headers.get('Authorization')
if not auth_header:
return False
try:
response = requests.get(
f'{BACKEND_URL}/api/auth/verify',
headers={'Authorization': auth_header},
timeout=3
)
return response.status_code == 200
except Exception as e:
logger.error(f"权限验证失败: {e}")
return False
def download_file(url, dest_path):
"""下载文件"""
logger.info(f"开始下载: {url}")
response = requests.get(url, stream=True, timeout=300)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
with open(dest_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
progress = int((downloaded / total_size) * 30) # 下载占30%
update_status['progress'] = progress
logger.info(f"下载完成: {dest_path}")
return dest_path
def backup_current_version():
"""备份当前版本"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_dir = BACKUPS_DIR / timestamp
backup_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"创建备份: {backup_dir}")
# 备份后端 JAR
if BACKEND_JAR.exists():
shutil.copy2(BACKEND_JAR, backup_dir / 'app.jar')
# 备份前端(打包)
if FRONTEND_DIR.exists():
frontend_backup = backup_dir / 'frontend.tar.gz'
with tarfile.open(frontend_backup, 'w:gz') as tar:
tar.add(FRONTEND_DIR, arcname='.')
# 备份版本信息
if VERSION_FILE.exists():
shutil.copy2(VERSION_FILE, backup_dir / 'version.json')
logger.info(f"备份完成: {backup_dir}")
return backup_dir
def restore_backup(backup_dir):
"""恢复备份"""
logger.info(f"开始恢复备份: {backup_dir}")
# 恢复后端 JAR
backup_jar = backup_dir / 'app.jar'
if backup_jar.exists():
shutil.copy2(backup_jar, BACKEND_JAR)
# 恢复前端
frontend_backup = backup_dir / 'frontend.tar.gz'
if frontend_backup.exists():
# 清空前端目录
if FRONTEND_DIR.exists():
shutil.rmtree(FRONTEND_DIR)
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
# 解压备份
with tarfile.open(frontend_backup, 'r:gz') as tar:
tar.extractall(FRONTEND_DIR)
# 恢复版本信息
backup_version = backup_dir / 'version.json'
if backup_version.exists():
shutil.copy2(backup_version, VERSION_FILE)
logger.info("备份恢复完成")
def perform_update(target_version):
"""执行更新流程"""
global update_status
try:
update_status['updating'] = True
update_status['progress'] = 0
update_status['message'] = '开始更新...'
update_status['error'] = None
# 1. 获取最新 Release
update_status['message'] = '获取 Release 信息...'
release = fetch_latest_release()
if not release:
raise Exception("无法获取 Release 信息")
tag = release['tag']
assets = release['assets']
# 查找更新包
update_asset = None
for asset in assets:
if asset['name'].endswith('-update.tar.gz'):
update_asset = asset
break
if not update_asset:
raise Exception(f"未找到更新包: {tag}")
update_status['progress'] = 10
# 2. 下载更新包
update_status['message'] = f'下载更新包 {tag}...'
download_url = update_asset['browser_download_url']
download_path = UPDATES_DIR / update_asset['name']
download_file(download_url, download_path)
update_status['progress'] = 40
# 3. 备份当前版本
update_status['message'] = '备份当前版本...'
backup_dir = backup_current_version()
update_status['progress'] = 50
# 4. 解压更新包
update_status['message'] = '解压更新包...'
extract_dir = UPDATES_DIR / 'current'
if extract_dir.exists():
shutil.rmtree(extract_dir)
extract_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(download_path, 'r:gz') as tar:
tar.extractall(extract_dir)
update_status['progress'] = 60
# 5. 停止后端进程
update_status['message'] = '停止后端服务...'
logger.info("停止后端进程...")
subprocess.run(['pkill', '-f', 'java -jar'], check=False)
time.sleep(2)
update_status['progress'] = 65
# 6. 替换文件
update_status['message'] = '更新文件...'
# 替换后端 JAR
new_jar = extract_dir / 'backend' / 'polyhermes.jar'
if new_jar.exists():
shutil.copy2(new_jar, BACKEND_JAR)
logger.info("后端 JAR 已更新")
# 替换前端文件
new_frontend = extract_dir / 'frontend'
if new_frontend.exists():
if FRONTEND_DIR.exists():
shutil.rmtree(FRONTEND_DIR)
shutil.copytree(new_frontend, FRONTEND_DIR)
logger.info("前端文件已更新")
# 更新版本信息
new_version = extract_dir / 'version.json'
if new_version.exists():
shutil.copy2(new_version, VERSION_FILE)
logger.info("版本信息已更新")
update_status['progress'] = 75
# 7. 重启后端服务
update_status['message'] = '重启后端服务...'
logger.info("重启后端服务...")
# 创建后端日志文件
backend_log_file = LOG_FILE.parent / 'backend-update.log'
backend_log = open(backend_log_file, 'w')
backend_process = subprocess.Popen([
'java', '-jar', str(BACKEND_JAR),
'--spring.profiles.active=prod'
], stdout=backend_log, stderr=subprocess.STDOUT, start_new_session=True)
logger.info(f"后端进程已启动 (PID: {backend_process.pid})")
update_status['progress'] = 80
# 8. 重载 Nginx
update_status['message'] = '重载 Nginx...'
subprocess.run(['nginx', '-s', 'reload'], check=True)
update_status['progress'] = 85
# 9. 健康检查
update_status['message'] = '健康检查...'
logger.info("等待后端服务启动...")
healthy = False
max_wait_time = 90 # 增加到90秒,给后端更多启动时间
last_process_check = 0
for i in range(max_wait_time):
# 每5秒检查一次进程状态
if i - last_process_check >= 5:
last_process_check = i
if backend_process.poll() is not None:
# 进程已退出
backend_log.close()
error_msg = ''
try:
with open(backend_log_file, 'r') as f:
lines = f.readlines()
error_msg = ''.join(lines[-50:]) # 读取最后50行
except:
pass
logger.error(f"后端进程异常退出(等待了 {i} 秒),退出码: {backend_process.returncode}")
if error_msg:
logger.error(f"后端日志最后50行:\n{error_msg}")
raise Exception(f"后端服务启动失败,退出码: {backend_process.returncode}")
else:
logger.debug(f"后端进程仍在运行 (PID: {backend_process.pid})")
# 尝试健康检查
try:
response = requests.get(f'{BACKEND_URL}/api/system/health', timeout=2)
if response.status_code == 200:
healthy = True
backend_log.close()
logger.info(f"健康检查通过(等待了 {i+1} 秒)")
break
except requests.exceptions.ConnectionError:
# 连接被拒绝,说明后端还没启动或端口未监听
if i % 10 == 0 and i > 0: # 每10秒记录一次
logger.debug(f"健康检查尝试 {i+1}/{max_wait_time}: 连接被拒绝(后端可能还在启动中)")
except requests.exceptions.Timeout:
# 超时
if i % 10 == 0: # 每10秒记录一次
logger.debug(f"健康检查尝试 {i+1}/{max_wait_time}: 请求超时")
except Exception as e:
logger.warning(f"健康检查异常: {e}")
time.sleep(1)
if not healthy:
# 关闭日志文件并尝试读取错误信息
backend_log.close()
error_msg = ''
try:
with open(backend_log_file, 'r') as f:
lines = f.readlines()
error_msg = ''.join(lines[-100:]) # 读取最后100行
except:
pass
# 检查进程状态
process_status = backend_process.poll()
if process_status is None:
# 进程还在运行,但健康检查失败
logger.error(f"健康检查失败:后端进程仍在运行 (PID: {backend_process.pid}),但无法访问健康检查端点")
logger.error("可能的原因:端口未监听、健康检查端点异常、或启动时间过长")
else:
# 进程已退出
logger.error(f"健康检查失败:后端进程已退出,退出码: {process_status}")
if error_msg:
logger.error(f"后端启动日志(最后100行):\n{error_msg}")
logger.error("健康检查失败,开始回滚...")
update_status['message'] = '健康检查失败,回滚中...'
# 确保后端进程已停止
try:
backend_process.terminate()
backend_process.wait(timeout=5)
except:
subprocess.run(['pkill', '-9', '-f', 'java.*app.jar'], check=False)
restore_backup(backup_dir)
# 等待一下再重启
time.sleep(2)
# 重启后端(使用旧版本)
logger.info("重启旧版本后端服务...")
subprocess.Popen([
'java', '-jar', str(BACKEND_JAR),
'--spring.profiles.active=prod'
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
subprocess.run(['nginx', '-s', 'reload'], check=True)
raise Exception(f"健康检查失败(等待了 {max_wait_time} 秒),已回滚到旧版本。请查看日志文件 {backend_log_file} 了解详情")
update_status['progress'] = 100
update_status['message'] = f'更新成功:{tag}'
logger.info(f"更新成功:{tag}")
# 清理临时文件
if download_path.exists():
download_path.unlink()
if extract_dir.exists():
shutil.rmtree(extract_dir)
except Exception as e:
logger.error(f"更新失败: {e}")
update_status['error'] = str(e)
update_status['message'] = f'更新失败: {str(e)}'
finally:
update_status['updating'] = False
# ==================== API 路由 ====================
@app.route('/health', methods=['GET'])
def health():
"""健康检查"""
return jsonify({'code': 0, 'data': 'ok', 'message': 'success'})
@app.route('/version', methods=['GET'])
def version():
"""获取当前版本"""
try:
if VERSION_FILE.exists():
with open(VERSION_FILE) as f:
data = json.load(f)
return jsonify({
'code': 0,
'data': {
'version': data.get('version', 'unknown'),
'tag': data.get('tag', 'unknown'),
'buildTime': data.get('buildTime', '')
},
'message': 'success'
})
else:
return jsonify({
'code': 0,
'data': {
'version': 'unknown',
'tag': 'unknown',
'buildTime': ''
},
'message': 'success'
})
except Exception as e:
logger.error(f"获取版本失败: {e}")
return jsonify({
'code': 500,
'data': None,
'message': str(e)
}), 500
@app.route('/check', methods=['GET'])
def check():
"""检查更新"""
try:
current_version = get_current_version()
release = fetch_latest_release()
if not release:
return jsonify({
'code': 500,
'data': None,
'message': '无法获取 Release 信息'
}), 500
latest_tag = release['tag']
latest_version = latest_tag.lstrip('v')
has_update = compare_versions(latest_version, current_version) > 0
return jsonify({
'code': 0,
'data': {
'hasUpdate': has_update,
'currentVersion': current_version,
'latestVersion': latest_version,
'latestTag': latest_tag,
'releaseNotes': release.get('body', ''),
'publishedAt': release.get('published_at', ''),
'prerelease': release.get('prerelease', False)
},
'message': 'success'
})
except Exception as e:
logger.error(f"检查更新失败: {e}")
return jsonify({
'code': 500,
'data': None,
'message': str(e)
}), 500
@app.route('/update', methods=['POST'])
def update():
"""执行更新(需要管理员权限)"""
# 权限检查
if not check_admin_permission(request):
return jsonify({
'code': 403,
'data': None,
'message': '需要管理员权限'
}), 403
if update_status['updating']:
return jsonify({
'code': 409,
'data': None,
'message': '正在更新中,请稍后'
}), 409
# 异步执行更新
thread = Thread(target=perform_update, args=('latest',))
thread.start()
return jsonify({
'code': 0,
'data': '更新已启动',
'message': 'success'
})
@app.route('/status', methods=['GET'])
def status():
"""获取更新状态"""
return jsonify({
'code': 0,
'data': {
'updating': update_status['updating'],
'progress': update_status['progress'],
'message': update_status['message'],
'error': update_status['error']
},
'message': 'success'
})
@app.route('/logs', methods=['GET'])
def logs():
"""获取更新日志(需要管理员权限)"""
# 权限检查
if not check_admin_permission(request):
return jsonify({
'code': 403,
'data': None,
'message': '需要管理员权限'
}), 403
try:
if LOG_FILE.exists():
with open(LOG_FILE) as f:
lines = f.readlines()
return jsonify({
'code': 0,
'data': ''.join(lines[-1000:]), # 最后1000行
'message': 'success'
})
return jsonify({
'code': 0,
'data': '',
'message': 'success'
})
except Exception as e:
logger.error(f"获取日志失败: {e}")
return jsonify({
'code': 500,
'data': None,
'message': str(e)
}), 500
# ==================== 主程序 ====================
if __name__ == '__main__':
logger.info("=" * 50)
logger.info("PolyHermes 更新服务启动")
logger.info(f"GitHub 仓库: {GITHUB_REPO}")
logger.info(f"允许 Pre-release: {ALLOW_PRERELEASE}")
logger.info(f"当前版本: {get_current_version()}")
logger.info("=" * 50)
# 启动 Flask 服务
app.run(host='0.0.0.0', port=9090, debug=False)
+335
View File
@@ -0,0 +1,335 @@
# PolyHermes 一键部署指南
本文档介绍如何使用交互式一键部署脚本快速部署 PolyHermes 应用。
## 📋 前置要求
### 必需软件
- **Docker**: 版本 20.10 或更高
- **Docker Compose**: 版本 2.0 或更高(或 `docker-compose` v1.29+
### 环境准备
```bash
# macOS 安装 Docker
brew install docker
# Ubuntu/Debian 安装 Docker
curl -fsSL https://get.docker.com | sh
# CentOS/RHEL 安装 Docker
yum install docker-ce docker-ce-cli containerd.io
```
## 🚀 快速开始
### 1. 克隆项目(如果尚未克隆)
```bash
git clone https://github.com/WrBug/PolyHermes.git
cd PolyHermes
```
### 2. 运行部署脚本
```bash
./deploy-interactive.sh
```
### 3. 按提示配置
脚本会引导你完成以下配置:
#### 基础配置
- **服务器端口**:应用对外暴露的端口(默认:80
- **MySQL 端口**:数据库外部访问端口(默认:3307,避免与本地 MySQL 冲突)
- **时区**:服务器时区(默认:Asia/Shanghai
#### 数据库配置
- **数据库用户名**:默认为 `root`
- **数据库密码**:回车自动生成 32 字符随机密码
#### 安全配置(关键)
- **JWT 密钥**:回车自动生成 128 字符随机密钥
- **管理员密码重置密钥**:回车自动生成 64 字符随机密钥
- **加密密钥**:用于加密 API Key,回车自动生成 64 字符随机密钥
#### 日志配置
- **Root 日志级别**:第三方库日志级别(默认:WARN
- 可选:`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`
- **应用日志级别**:应用代码日志级别(默认:INFO
#### 其他配置
- **Spring Profile**:环境配置(默认:prod
- **允许预发布版本**:是否允许自动更新到预发布版本(默认:false)
- **GitHub 仓库**:项目仓库地址(默认:WrBug/PolyHermes
### 4. 确认并部署
配置完成后,脚本会:
1. 生成 `.env` 配置文件
2. 显示配置摘要
3. 请求确认部署
4. 拉取 Docker Hub 镜像
5. 启动服务
6. 执行健康检查
## 📝 配置示例
### 使用默认值部署(推荐)
所有配置项直接回车,脚本会自动生成安全的随机密钥:
```
服务器端口 [默认: 80]: ⏎
MySQL 端口(外部访问) [默认: 3307]: ⏎
时区 [默认: Asia/Shanghai]: ⏎
数据库用户名 [默认: root]: ⏎
数据库密码 [回车自动生成]: ⏎
JWT 密钥 [回车自动生成]: ⏎
管理员密码重置密钥 [回车自动生成]: ⏎
加密密钥(用于加密 API Key) [回车自动生成]: ⏎
Root 日志级别(第三方库) [默认: WARN]: ⏎
应用日志级别 [默认: INFO]: ⏎
Spring Profile [默认: prod]: ⏎
允许预发布版本更新 [默认: false]: ⏎
GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
```
### 自定义端口部署
如果需要使用不同的端口:
```
服务器端口 [默认: 80]: 8080⏎
MySQL 端口(外部访问) [默认: 3307]: 33306⏎
```
### 开发环境部署
启用 DEBUG 日志:
```
Root 日志级别(第三方库) [默认: WARN]: DEBUG⏎
应用日志级别 [默认: INFO]: DEBUG⏎
```
## 🔧 部署后管理
### 访问应用
部署完成后,访问:
```
http://localhost:80
```
(或你配置的自定义端口)
### 常用命令
```bash
# 查看服务状态
docker compose -f docker-compose.prod.yml ps
# 查看实时日志
docker compose -f docker-compose.prod.yml logs -f
# 仅查看应用日志
docker compose -f docker-compose.prod.yml logs -f app
# 停止服务
docker compose -f docker-compose.prod.yml down
# 重启服务
docker compose -f docker-compose.prod.yml restart
# 更新到最新版本
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
```
### 数据库连接
使用配置的凭据连接到 MySQL
```bash
mysql -h 127.0.0.1 -P 3307 -u root -p
# 输入你在部署时设置的数据库密码
```
或使用图形化工具(如 DBeaver、Navicat):
- **主机**: `localhost`
- **端口**: `3307`(或你配置的端口)
- **数据库**: `polyhermes`
- **用户名**: `root`(或你配置的用户名)
- **密码**: 部署时设置的密码(可在 `.env` 文件中查看)
## 🔐 安全最佳实践
### 保护配置文件
```bash
# 设置 .env 文件权限
chmod 600 .env
# 确保 .env 已添加到 .gitignore
echo ".env" >> .gitignore
```
### 定期更换密钥
生产环境建议定期更换安全密钥:
```bash
# 生成新的 JWT 密钥(128字符)
openssl rand -hex 64
# 生成新的管理员重置密钥(64字符)
openssl rand -hex 32
# 更新 .env 文件后重启服务
docker compose -f docker-compose.prod.yml restart
```
### 备份数据库
```bash
# 备份数据库
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d).sql
# 恢复数据库
docker exec -i polyhermes-mysql mysql -u root -p polyhermes < backup_20260201.sql
```
## 🌐 生产环境部署建议
### 1. 使用反向代理
建议使用 Nginx 或 Caddy 作为反向代理:
```nginx
# Nginx 配置示例
server {
listen 443 ssl http2;
server_name polyhermes.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 2. 配置防火墙
```bash
# UFW (Ubuntu)
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Firewalld (CentOS)
firewall-cmd --add-service=http --permanent
firewall-cmd --add-service=https --permanent
firewall-cmd --reload
```
### 3. 启用自动更新
配置定时任务自动检查并更新镜像:
```bash
# 创建更新脚本
cat > /opt/polyhermes-update.sh <<'EOF'
#!/bin/bash
cd /path/to/PolyHermes
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
EOF
chmod +x /opt/polyhermes-update.sh
# 添加到 crontab(每天凌晨 3 点检查更新)
echo "0 3 * * * /opt/polyhermes-update.sh >> /var/log/polyhermes-update.log 2>&1" | crontab -
```
## 🐛 故障排查
### 服务无法启动
```bash
# 查看详细错误日志
docker compose -f docker-compose.prod.yml logs
# 检查容器状态
docker compose -f docker-compose.prod.yml ps
```
### 数据库连接失败
```bash
# 检查 MySQL 容器状态
docker logs polyhermes-mysql
# 测试数据库连接
docker exec polyhermes-mysql mysql -u root -p -e "SELECT 1"
```
### 端口被占用
```bash
# 查找占用端口的进程
lsof -i :80
# 或
netstat -tulpn | grep :80
# 修改 SERVER_PORT 环境变量
vim .env # 修改 SERVER_PORT=8080
docker compose -f docker-compose.prod.yml up -d
```
### 镜像拉取失败
如果 Docker Hub 访问受限,可以配置镜像加速器:
```bash
# 配置 Docker 镜像加速器
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com"
]
}
EOF
sudo systemctl restart docker
```
## 📚 更多资源
- [项目 README](../../README.md)
- [发布日志](../../RELEASE.md)
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
## 📞 获取帮助
如遇到问题,请:
1. 查看上方的**故障排查**章节
2. 检查 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
3. 提交新的 Issue 并附上日志输出
---
**祝部署顺利!** 🎉
+324
View File
@@ -0,0 +1,324 @@
# Docker 版本号确定流程
## 概述
Docker 镜像的版本号从 **GitHub Release Tag** 获取,通过 GitHub Actions 自动传递到 Dockerfile,最终存储在容器内的 `/app/version.json` 文件中。
## 完整流程
```
1. GitHub Release Tag (v1.0.0)
2. GitHub Actions 触发
3. 从 Tag 提取版本号
4. 作为 build-args 传递给 Dockerfile
5. Dockerfile 写入 /app/version.json
6. 容器运行时读取版本号
```
## 详细步骤
### 步骤 1: 创建 GitHub Release
通过 GitHub Releases 页面或 `create-release.sh` 脚本创建 Release
```bash
# 示例:创建 v1.0.1 版本
./create-release.sh -t v1.0.1 -T "Release v1.0.1" -d "更新内容"
```
**结果**:
- 创建 Git tag: `v1.0.1`
- 创建 GitHub Release: `v1.0.1`
- 触发 GitHub Actions workflow
### 步骤 2: GitHub Actions 触发
GitHub Actions 监听 `release: published` 事件:
```yaml
# .github/workflows/docker-build.yml
on:
release:
types:
- published # 当创建 release 时触发
```
**事件数据**:
- `github.event.release.tag_name`: `"v1.0.1"`
- `github.event.release.prerelease`: `false``true`
### 步骤 3: 提取版本号
GitHub Actions 从 Tag 中提取版本号:
```bash
# .github/workflows/docker-build.yml (步骤: Extract version)
TAG_NAME="${{ github.event.release.tag_name }}" # "v1.0.1"
VERSION=${TAG_NAME#v} # "1.0.1" (移除 v 前缀)
```
**提取结果**:
- `VERSION`: `"1.0.1"` (纯版本号,无 v 前缀)
- `TAG`: `"v1.0.1"` (完整 tag,带 v 前缀)
- `IS_PRERELEASE`: `false``true`
**版本号格式验证**:
- ✅ 正确:`v1.0.0`, `v2.10.102`, `v1.0.0-beta`
- ❌ 错误:`v1.0`, `1.0.0`, `v1.0.0.1`
### 步骤 4: 传递构建参数
版本号作为 Docker build-args 传递给 Dockerfile
```yaml
# .github/workflows/docker-build.yml
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
build-args: |
BUILD_IN_DOCKER=false
VERSION=${{ steps.extract_version.outputs.VERSION }} # "1.0.1"
GIT_TAG=${{ steps.extract_version.outputs.TAG }} # "v1.0.1"
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
```
### 步骤 5: Dockerfile 接收参数
Dockerfile 使用 ARG 接收构建参数:
```dockerfile
# Dockerfile (第 92-94 行)
ARG VERSION=dev # 默认值: dev
ARG GIT_TAG=dev # 默认值: dev
# 写入 version.json
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
```
**生成的文件内容** (`/app/version.json`):
```json
{
"version": "1.0.1",
"tag": "v1.0.1",
"buildTime": "2026-01-20T15:30:00Z"
}
```
### 步骤 6: 容器运行时读取
更新服务通过 `/api/update/version` 接口读取版本号:
```python
# docker/update-service.py
def get_current_version():
"""获取当前版本"""
if VERSION_FILE.exists():
with open(VERSION_FILE) as f:
data = json.load(f)
return data.get('version', 'unknown') # 返回: "1.0.1"
```
前端通过 API 获取并显示:
```typescript
// frontend/src/pages/SystemUpdate.tsx
const response = await apiClient.get('/update/version')
const { version } = response.data.data // "1.0.1"
```
## 不同场景下的版本号
### 场景 1: GitHub Actions 自动构建(正式发布)
**输入**:
- Release Tag: `v1.0.1`
- Release Type: Published (正式版本)
**流程**:
1. GitHub Actions 提取: `VERSION="1.0.1"`, `GIT_TAG="v1.0.1"`
2. 传递给 Dockerfile
3. 生成 `/app/version.json`: `{"version": "1.0.1", "tag": "v1.0.1", ...}`
**Docker 镜像标签**:
- `wrbug/polyhermes:v1.0.1`
- `wrbug/polyhermes:latest` ✅ (因为不是 pre-release)
### 场景 2: Pre-release(测试版本)
**输入**:
- Release Tag: `v1.0.1-beta`
- Release Type: Pre-release
**流程**:
1. GitHub Actions 提取: `VERSION="1.0.1-beta"`, `GIT_TAG="v1.0.1-beta"`
2. 传递给 Dockerfile
3. 生成 `/app/version.json`: `{"version": "1.0.1-beta", "tag": "v1.0.1-beta", ...}`
**Docker 镜像标签**:
- `wrbug/polyhermes:v1.0.1-beta`
- `wrbug/polyhermes:latest` ❌ (pre-release 不推送到 latest)
### 场景 3: 本地构建(开发环境)
**命令行**:
```bash
docker build -t polyhermes:local .
```
**流程**:
1. 没有传递 `VERSION``GIT_TAG` 参数
2. Dockerfile 使用默认值: `VERSION=dev`, `GIT_TAG=dev`
3. 生成 `/app/version.json`: `{"version": "dev", "tag": "dev", ...}`
**显式指定版本号**:
```bash
docker build \
--build-arg VERSION=1.0.1 \
--build-arg GIT_TAG=v1.0.1 \
-t polyhermes:local .
```
### 场景 4: 本地 Docker Compose
**docker-compose.yml**:
```yaml
services:
app:
build:
context: .
args:
VERSION: 1.0.1
GIT_TAG: v1.0.1
```
## 版本号存储位置
### 容器内路径
```
/app/version.json
```
### 文件格式
```json
{
"version": "1.0.1", // 纯版本号(无 v 前缀)
"tag": "v1.0.1", // 完整 tag(带 v 前缀)
"buildTime": "2026-01-20T15:30:00Z" // 构建时间(UTC
}
```
### 访问方式
**1. 通过 API**:
```bash
curl http://localhost/api/update/version
```
**2. 进入容器查看**:
```bash
docker exec -it <container_id> cat /app/version.json
```
**3. 前端显示**:
- 系统设置 → 系统更新页面
- 显示当前版本: `v1.0.1`
## 版本号的作用
### 1. 显示当前版本
前端和系统更新页面显示当前运行的版本号。
### 2. 检查更新
更新服务通过比较当前版本和 GitHub 最新版本判断是否有更新:
```python
# docker/update-service.py
current_version = get_current_version() # "1.0.1"
latest_version = fetch_latest_release() # "1.0.2"
if compare_versions(latest_version, current_version) > 0:
# 有新版本,提示更新
```
### 3. 版本追踪
记录 Docker 镜像的构建版本,便于追踪和回滚。
## 关键文件
| 文件 | 作用 | 版本号来源 |
|------|------|-----------|
| `.github/workflows/docker-build.yml` | GitHub Actions 工作流 | `github.event.release.tag_name` |
| `Dockerfile` | Docker 构建配置 | 构建参数 `VERSION`, `GIT_TAG` |
| `/app/version.json` | 版本号存储文件 | Dockerfile 生成 |
| `docker/update-service.py` | 更新服务 | 读取 `/app/version.json` |
## 常见问题
### Q1: 为什么版本号是 `dev`
**A**: 本地构建时没有传递版本号参数,使用了默认值。
**解决**:
```bash
docker build \
--build-arg VERSION=1.0.1 \
--build-arg GIT_TAG=v1.0.1 \
-t polyhermes:local .
```
### Q2: 如何查看当前容器的版本号?
**A**:
```bash
# 方法1: API 接口
curl http://localhost/api/update/version
# 方法2: 进入容器
docker exec -it <container_id> cat /app/version.json
# 方法3: 前端页面
系统设置 → 系统更新 → 查看"当前版本"
```
### Q3: 版本号格式错误怎么办?
**A**: GitHub Actions 会验证版本号格式:
- ✅ 正确:`v1.0.0`, `v1.0.0-beta`
- ❌ 错误:`v1.0`, `1.0.0`
如果格式错误,构建会失败并提示错误信息。
### Q4: Pre-release 和正式版本的版本号有什么区别?
**A**:
- **格式**: 都可以使用相同的格式(`v1.0.1-beta` vs `v1.0.1`
- **存储**: 都存储在 `/app/version.json`
- **Docker 标签**: Pre-release 不会推送到 `latest` 标签
- **通知**: Pre-release 不会发送 Telegram 通知
## 总结
Docker 版本号的确定流程:
1. **来源**: GitHub Release Tag
2. **提取**: GitHub Actions 从 tag 中提取版本号
3. **传递**: 通过 Docker build-args 传递
4. **存储**: 写入容器内的 `/app/version.json`
5. **使用**: 用于显示、检查更新、版本追踪
关键点:
- ✅ 版本号来自 **GitHub Release Tag**
- ✅ 格式必须符合:`v数字.数字.数字[-后缀]`
- ✅ 默认值为 `dev`(本地构建时)
- ✅ 支持 Pre-release 标记
File diff suppressed because it is too large Load Diff

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