diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ff9b24e..c3f97e2 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -36,6 +36,49 @@ jobs: echo "Extracted version: $VERSION" echo "Full tag: $TAG_NAME" + - name: Send Telegram notification (build started) + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + # 检查必要的环境变量 + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知" + exit 0 + fi + + # 获取构建信息 + VERSION="${{ steps.extract_version.outputs.VERSION }}" + TAG="${{ steps.extract_version.outputs.TAG }}" + RELEASE_URL="${{ github.event.release.html_url }}" + + # 构建消息内容(仅包含关键信息) + MESSAGE="🔨 Docker 镜像构建中"$'\n'$'\n'"📦 版本: ${VERSION}"$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔗 查看 Release" + + # 发送 Telegram 消息(使用 jq 转义 JSON) + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MESSAGE" \ + '{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json + + # 检查发送结果 + if [ $? -eq 0 ]; then + RESPONSE=$(cat /tmp/telegram_response.json) + if echo "$RESPONSE" | grep -q '"ok":true'; then + echo "✅ Telegram 通知发送成功" + else + echo "❌ Telegram 通知发送失败: $RESPONSE" + # 通知失败不应该导致整个 job 失败 + exit 0 + fi + else + echo "❌ 发送 Telegram 消息时发生错误" + # 通知失败不应该导致整个 job 失败 + exit 0 + fi + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: @@ -65,3 +108,49 @@ jobs: GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes cache-from: type=registry,ref=wrbug/polyhermes:latest cache-to: type=inline + + - name: Send Telegram notification + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + # 检查必要的环境变量 + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知" + exit 0 + fi + + # 获取构建信息 + VERSION="${{ steps.extract_version.outputs.VERSION }}" + TAG="${{ steps.extract_version.outputs.TAG }}" + RELEASE_NAME="${{ github.event.release.name }}" + RELEASE_URL="${{ github.event.release.html_url }}" + REPO_NAME="${{ github.repository }}" + + # 构建消息内容(仅包含关键信息) + DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md" + MESSAGE="✅ Docker 镜像构建成功"$'\n'$'\n'"📦 版本: ${VERSION}"$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔗 查看 Release"$'\n'"📚 Docker 部署文档" + + # 发送 Telegram 消息(使用 jq 转义 JSON) + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MESSAGE" \ + '{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json + + # 检查发送结果 + if [ $? -eq 0 ]; then + RESPONSE=$(cat /tmp/telegram_response.json) + if echo "$RESPONSE" | grep -q '"ok":true'; then + echo "✅ Telegram 通知发送成功" + else + echo "❌ Telegram 通知发送失败: $RESPONSE" + # 构建成功,通知失败不应该导致整个 job 失败 + exit 0 + fi + else + echo "❌ 发送 Telegram 消息时发生错误" + # 构建成功,通知失败不应该导致整个 job 失败 + exit 0 + fi \ No newline at end of file diff --git a/.github/workflows/telegram-notify.yml b/.github/workflows/telegram-notify.yml new file mode 100644 index 0000000..7ae205c --- /dev/null +++ b/.github/workflows/telegram-notify.yml @@ -0,0 +1,103 @@ +name: Telegram Notification on PR Merge + +on: + pull_request: + types: + - closed # 当 PR 被关闭(合并或关闭)时触发 + +jobs: + notify: + runs-on: ubuntu-latest + + # 只在 PR 被合并到 main 分支时执行 + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get PR details + id: pr_details + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + REPO="${{ github.repository }}" + + # 获取 PR 详细信息 + PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}") + + # 获取 PR 变更的文件列表 + FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files") + + # 提取 PR 描述(body),保留换行,限制长度 + PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""') + if [ ${#PR_BODY} -gt 500 ]; then + PR_BODY="${PR_BODY:0:500}..." + fi + + # 保存到输出变量(使用 base64 编码避免特殊字符问题) + echo "pr_body<> $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/&/\&/g' | sed 's//\>/g') + + # 构建消息内容(仅包含关键信息) + MESSAGE="🚀 main 分支代码更新"$'\n'$'\n'"📝 PR #${PR_NUMBER}: ${PR_TITLE_ESCAPED}"$'\n'"🔗 查看 PR" + + # 发送 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 + diff --git a/.gitignore b/.gitignore index e526be3..4d4b0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,5 @@ __pycache__/ # Submodules and external dependencies clob-client/ builder-relayer-client/ +landing-page/ diff --git a/ANNOUNCEMENT_v1.0.1.md b/ANNOUNCEMENT_v1.0.1.md deleted file mode 100644 index 6d15284..0000000 --- a/ANNOUNCEMENT_v1.0.1.md +++ /dev/null @@ -1,133 +0,0 @@ -# 🎉 v1.0.1 版本发布公告 - -## 📅 发布日期 - -2025年12月(具体日期待定) - -## 🚀 主要更新 - -### 性能优化 - -#### 📊 订单簿请求优化 -- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用 -- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取 -- 订单簿只请求一次,在过滤检查中复用,提高性能 - -#### 💰 卖出价格计算优化 -- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价) -- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格 -- 卖出价格固定按 90% 计算,不再使用价格容忍度配置 -- 提高卖出订单的成交率 - -#### ⚙️ 价格容忍度默认值 -- 如果价格容忍度配置为 0,自动使用默认值 5% -- 确保买入订单能够正常应用价格调整,提高成交率 - -### 代码优化 - -#### 🎯 使用枚举优化过滤逻辑 -- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举 -- 使用类型安全的枚举替代字符串判断,提高代码可维护性 -- 移除无用字段 `isBuyOrder` 参数 -- 优化 `checkFilters` 方法返回值,使用数据类封装结果 - -#### 📝 代码结构优化 -- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符 -- 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码 -- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性 - -### 功能改进 - -#### 🗑️ 移除最小订单簿深度功能 -- 移除 `minOrderbookDepth`(最小订单簿深度)配置项 -- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度) -- 更新前端界面,移除相关配置项和提示文案 - -#### 📈 优化最小订单深度逻辑 -- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度 -- 不再区分买卖方向,提供更全面的市场流动性评估 - -#### 🎯 市场结算判断优化 -- 添加市场已关闭时的结算判断逻辑 -- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1 -- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0 -- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格 - -### Bug 修复 - -- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题 -- ✅ 修复过滤检查中订单簿可能重复请求的问题 -- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`) - -## 📦 如何更新 - -### Docker 部署 - -```bash -# 拉取最新镜像 -docker pull wrbug/polyhermes:v1.0.1 - -# 或使用 latest 标签 -docker pull wrbug/polyhermes:latest - -# 重启容器 -docker-compose down -docker-compose up -d -``` - -### 源码部署 - -```bash -# 拉取最新代码 -git fetch origin -git checkout v1.0.1 - -# 重新构建 -cd backend -./gradlew bootJar - -# 重启服务 -# 根据您的部署方式重启服务 -``` - -## 📚 文档更新 - -- 更新前端多语言文案,优化提示信息 -- 移除最小订单簿深度相关的文档说明 - -## 🔗 相关链接 - -- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1 -- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1 -- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes - -## ⚠️ 重要提醒 - -**请务必使用官方 Docker 镜像源,避免财产损失!** - -### ✅ 官方 Docker Hub 镜像 - -**官方镜像地址**:`wrbug/polyhermes` - -```bash -# ✅ 正确:使用官方镜像 -docker pull wrbug/polyhermes:v1.0.1 - -# ❌ 错误:不要使用其他来源的镜像 -# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗 -``` - -### 🔗 官方渠道 - -请通过以下**唯一官方渠道**获取 PolyHermes: - -* **GitHub 仓库**:https://github.com/WrBug/PolyHermes -* **Twitter**:@polyhermes -* **Telegram 群组**:加入群组 - ---- - -**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!** - -**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。** - diff --git a/README.md b/README.md index 9f3bbad..e447516 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,25 @@ docker-compose up -d # image: wrbug/polyhermes:v1.0.0 ``` +**更新 Docker 版本**: + +```bash +# 1. 停止当前容器 +docker-compose -f docker-compose.prod.yml down + +# 2. 拉取最新镜像 +docker pull wrbug/polyhermes:latest + +# 3. 重新启动服务 +docker-compose -f docker-compose.prod.yml up -d + +# 或更新到特定版本(例如 v1.0.1) +# 修改 docker-compose.prod.yml 中的镜像标签为: image: wrbug/polyhermes:v1.0.1 +# 然后执行: docker-compose -f docker-compose.prod.yml up -d +``` + +详细更新说明请参考:[部署文档 - 更新 Docker 版本](docs/zh/DEPLOYMENT.md#更新-docker-版本) + 2. **本地构建部署(开发环境)** ```bash @@ -251,7 +270,6 @@ DB_USERNAME=root DB_PASSWORD=your_password_here SPRING_PROFILES_ACTIVE=prod SERVER_PORT=80 -POLYGON_RPC_URL=https://polygon-rpc.com JWT_SECRET=your-jwt-secret-key-change-in-production ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production EOF @@ -318,7 +336,6 @@ cd frontend | `DB_USERNAME` | 数据库用户名 | `root` | | `DB_PASSWORD` | 数据库密码 | - | | `SERVER_PORT` | 后端服务端口 | `8000` | -| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` | | `JWT_SECRET` | JWT 密钥 | - | | `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | | `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - | diff --git a/README_EN.md b/README_EN.md index dcb6fd5..4c72814 100644 --- a/README_EN.md +++ b/README_EN.md @@ -228,6 +228,25 @@ docker-compose up -d # image: wrbug/polyhermes:v1.0.0 ``` +**Update Docker Version**: + +```bash +# 1. Stop current containers +docker-compose -f docker-compose.prod.yml down + +# 2. Pull latest image +docker pull wrbug/polyhermes:latest + +# 3. Restart services +docker-compose -f docker-compose.prod.yml up -d + +# Or update to specific version (e.g., v1.0.1) +# Modify image tag in docker-compose.prod.yml to: image: wrbug/polyhermes:v1.0.1 +# Then run: docker-compose -f docker-compose.prod.yml up -d +``` + +For detailed update instructions, please refer to: [Deployment Guide - Update Docker Version](docs/en/DEPLOYMENT.md#update-docker-version) + 2. **Local Build Deployment (Development Environment)** ```bash @@ -251,7 +270,6 @@ DB_USERNAME=root DB_PASSWORD=your_password_here SPRING_PROFILES_ACTIVE=prod SERVER_PORT=80 -POLYGON_RPC_URL=https://polygon-rpc.com JWT_SECRET=your-jwt-secret-key-change-in-production ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production EOF @@ -318,7 +336,6 @@ cd frontend | `DB_USERNAME` | Database username | `root` | | `DB_PASSWORD` | Database password | - | | `SERVER_PORT` | Backend service port | `8000` | -| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` | | `JWT_SECRET` | JWT secret key | - | | `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | | `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - | diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..4710932 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,278 @@ +# 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 Bean(lenient 模式) +- 所有 Service 类通过构造函数注入 Gson 实例 +- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson +- 提高代码一致性和可维护性 + +### 🗑️ 功能清理 +- 移除下单失败存储数据库的功能 +- 删除 `FailedTrade` 实体类和 `FailedTradeRepository` +- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑 +- 创建 Flyway migration V16 删除 `failed_trade` 表 +- 下单失败时仅记录日志,不再存储到数据库,简化数据模型 + +### 🚀 部署优化 +- 自动使用当前分支名作为 Docker 版本号 +- 分支名中的 `/` 自动替换为 `-`(Docker tag 不支持 `/) +- `docker-compose.yml` 启用 build args,从环境变量读取版本号 +- 前端页面将显示当前分支名作为版本号 +- 如果没有 Git 仓库或获取失败,使用默认值 `dev` + +## 🐛 Bug 修复 + +### 修复 Flyway Migration 问题 +- 恢复 V1 migration 文件,避免 checksum 不匹配 +- 保持 `V1__init_database.sql` 的原有内容不变 +- `failed_trade` 表的删除通过 V16 migration 处理 +- 确保已有数据库的 migration checksum 保持一致 + +### 修复前端编译错误 +- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误 +- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译 + +## 📚 文档更新 + +- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`) +- 详细说明智能资金分析功能的使用方法和策略 + +## 🔧 技术改进 + +- 优化 `RetrofitFactory`,实现客户端实例缓存和复用 +- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑 +- 优化 `OrderStatusUpdateService`,增强订单状态更新功能 +- 优化 `TelegramNotificationService`,改进通知逻辑 +- 优化 `PositionCheckService`,简化代码结构 +- 优化 `PolymarketClobService`,改进 API 调用逻辑 + +## 📦 数据库变更 + +- 删除 `failed_trade` 表(Migration: V16) + +## 🔗 相关链接 + +- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2 +- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2 +- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes + +## 📊 统计信息 + +- **文件变更**: 29 个文件 +- **代码变更**: +1597 行 / -678 行 +- **主要提交**: 8 个提交 + +## ⚠️ 重要提醒 + +**请务必使用官方 Docker 镜像源,避免财产损失!** + +### ✅ 官方 Docker Hub 镜像 + +**官方镜像地址**:`wrbug/polyhermes` + +```bash +# ✅ 正确:使用官方镜像 +docker pull wrbug/polyhermes:v1.1.2 + +# ❌ 错误:不要使用其他来源的镜像 +# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗 +``` + +### 🔗 官方渠道 + +请通过以下**唯一官方渠道**获取 PolyHermes: + +* **GitHub 仓库**:https://github.com/WrBug/PolyHermes +* **Twitter**:@polyhermes +* **Telegram 群组**:加入群组 + +--- + +**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!** + +--- + +# v1.1.1 + +## 🚀 主要功能 + +### 🔗 链上 WebSocket 监听优化 +- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接 +- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能 +- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件 +- 优化 `OnChainWsService`,复用公共代码,减少代码重复 +- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态 + +### 📊 市场状态查询优化 +- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition` +- 如果链上查询失败,自动降级到 Gamma API 查询 +- 提供更实时和准确的市场结算结果 + +### 🔕 自动订单通知优化 +- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知 +- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理 +- 减少不必要的通知,提升用户体验 + +## 🐛 Bug 修复 + +### 修复移动端 API 健康页面缺少数据显示 +- 移动端添加 URL 地址显示 +- 移动端添加状态文本显示(正常/异常/未配置) +- 移动端添加消息/状态信息显示 +- 移动端和桌面端显示信息保持一致 + +## 🔧 功能优化 + +### 优化 Telegram 推送消息格式 +- 添加价格和数量截位处理: + * 价格保留最多4位小数(截断,不四舍五入) + * 数量保留最多2位小数(截断,不四舍五入) +- 优化账户信息显示格式: + * 有账户名和钱包地址时显示:账户名(0x123...123) + * 只有账户名时显示账户名 + * 只有钱包地址时显示脱敏后的地址 + * 都没有时显示未知账户 + +### 配置优化 +- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点 +- 删除无用的 `position.push.polling-interval` 和 `position.push.heartbeat-timeout` 配置项 +- 修正日志配置中的包名(polyhermes -> polymarketbot) +- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()` + +## 📚 文档更新 + +- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md) +- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明 +- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量 +- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义 + +## 🔧 技术改进 + +- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils` +- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService` +- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`) +- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`) +- 优化代码结构,减少代码重复,提高可维护性 + +--- + +# v1.1.0 + +## 🚀 主要功能 + +### 🔗 链上 WebSocket 实时监听 +- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易 +- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件 +- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个 +- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接 +- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消 +- 跟单配置生效/失效时及时更新 WebSocket 订阅 +- 使用 Gson 替换所有 JSON 解析,提高解析稳定性 +- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复 + +### 📊 RPC 节点管理 +- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点 +- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略 +- 前端添加启用/禁用开关,支持实时切换节点状态 +- 健康检查只检查启用的节点,提高检查效率 +- 节点选择时自动过滤禁用的节点 + +### 💰 卖出订单价格轮询更新 +- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新 +- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次: + - 更新卖出订单的实际成交价(通过 orderId 查询订单详情) + - 清理已删除账户的订单记录 +- 支持加权平均价格计算,处理部分成交的订单 +- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新 +- 下单完成后不再立即查询价格,直接保存,等待定时任务更新 + +## 🐛 Bug 修复 + +### 修复跟单卖出订单的 API 凭证解密问题 +- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致 +- 确保卖出订单能够正常使用 API 凭证进行认证 + +### 修复 SELL 订单精度问题 +- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题: + - `makerAmount` (shares) 最多 2 位小数(符合 API 要求) + - `takerAmount` (USDC) 最多 4 位小数(符合 API 要求) +- 确保订单能够正常提交到 Polymarket API + +## 📚 文档更新 + +- 添加 Docker 版本更新说明(中英文) +- 添加链上 WebSocket 监听策略文档 +- 添加跟单逻辑总结文档 +- 更新部署文档,包含详细的版本更新步骤 + +## 🔧 技术改进 + +- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性 +- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构 +- 优化 WebSocket 连接管理,减少不必要的连接 +- 添加线程安全机制,使用 Kotlin Coroutines Mutex +- 启用 Spring 定时任务功能(`@EnableScheduling`) + +## 📦 数据库变更 + +- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13) + +## 🔗 相关链接 + +- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1 +- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1 +- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes + +## 📊 统计信息 + +- **文件变更**: 32 个文件 +- **代码变更**: +1872 行 / -1503 行 +- **主要提交**: 7 个提交 + +## ⚠️ 重要提醒 + +**请务必使用官方 Docker 镜像源,避免财产损失!** + +### ✅ 官方 Docker Hub 镜像 + +**官方镜像地址**:`wrbug/polyhermes` + +```bash +# ✅ 正确:使用官方镜像 +docker pull wrbug/polyhermes:v1.1.1 + +# ❌ 错误:不要使用其他来源的镜像 +# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗 +``` + +### 🔗 官方渠道 + +请通过以下**唯一官方渠道**获取 PolyHermes: + +* **GitHub 仓库**:https://github.com/WrBug/PolyHermes +* **Twitter**:@polyhermes +* **Telegram 群组**:加入群组 + +--- + +**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!** + + diff --git a/RELEASE_v1.0.1.md b/RELEASE_v1.0.1.md deleted file mode 100644 index b7b0f93..0000000 --- a/RELEASE_v1.0.1.md +++ /dev/null @@ -1,55 +0,0 @@ -# v1.0.1 - -## 🚀 性能优化 - -### 📊 订单簿请求优化 -* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用 -* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取 -* 订单簿只请求一次,在过滤检查中复用,提高性能 - -### 💰 卖出价格计算优化 -* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价) -* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格 -* 卖出价格固定按 90% 计算,不再使用价格容忍度配置 -* 提高卖出订单的成交率 - -### ⚙️ 价格容忍度默认值 -* 如果价格容忍度配置为 0,自动使用默认值 5% -* 确保买入订单能够正常应用价格调整,提高成交率 - -## 🔧 代码优化 - -### 🎯 使用枚举优化过滤逻辑 -* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举 -* 使用类型安全的枚举替代字符串判断,提高代码可维护性 -* 移除无用字段 `isBuyOrder` 参数 -* 优化 `checkFilters` 方法返回值,使用数据类封装结果 - -### 📝 代码结构优化 -* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符 -* 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码 -* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性 - -## 🗑️ 功能移除 - -### 移除最小订单簿深度功能 -* 移除 `minOrderbookDepth`(最小订单簿深度)配置项 -* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度) -* 更新前端界面,移除相关配置项和提示文案 - -### 优化最小订单深度逻辑 -* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度 -* 不再区分买卖方向,提供更全面的市场流动性评估 - -## 🐛 Bug 修复 - -* 修复盈亏和持仓价值计算逻辑未实现的问题 -* 修复过滤检查中订单簿可能重复请求的问题 - -## 📚 文档更新 - -* 更新前端多语言文案,优化提示信息 -* 移除最小订单簿深度相关的文档说明 - -**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1 - diff --git a/backend/deploy.sh b/backend/deploy.sh index bba3945..c9d18ca 100755 --- a/backend/deploy.sh +++ b/backend/deploy.sh @@ -154,9 +154,6 @@ SPRING_PROFILES_ACTIVE=prod # 服务器端口 SERVER_PORT=8000 -# Polygon RPC -POLYGON_RPC_URL=https://polygon-rpc.com - # JWT 密钥(已自动生成随机值,生产环境建议修改) JWT_SECRET=${JWT_SECRET} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 926fd2d..1e2b4fb 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -14,7 +14,6 @@ services: - DB_USERNAME=${DB_USERNAME:-root} - DB_PASSWORD=${DB_PASSWORD:-} - SERVER_PORT=8000 - - POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com} - JWT_SECRET=${JWT_SECRET:-change-me-in-production} - ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production} depends_on: diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt index a8404f8..c48c2f7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt @@ -2,8 +2,10 @@ package com.wrbug.polymarketbot import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling class PolymarketBotApplication fun main(args: Array) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt index 34385fc..0a5be45 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt @@ -1,5 +1,6 @@ package com.wrbug.polymarketbot.api +import com.google.gson.JsonElement import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST @@ -29,10 +30,11 @@ data class JsonRpcRequest( /** * JSON-RPC 响应 + * 使用 JsonElement 类型处理 result 字段,可以灵活处理字符串、对象、数组等类型 */ data class JsonRpcResponse( val jsonrpc: String? = null, - val result: String? = null, + val result: JsonElement? = null, // 使用 JsonElement 类型,可以处理任意 JSON 类型 val error: JsonRpcError? = null, val id: Int? = null ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt index 2bc27d1..7d56d7e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt @@ -24,6 +24,7 @@ interface PolymarketGammaApi { * 根据条件获取市场信息 * 文档: https://docs.polymarket.com/api-reference/markets/list-markets * @param conditionIds condition ID 数组(16 进制字符串,如 "0x...") + * @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场) * @param includeTag 是否包含标签信息 * @param tags 标签 ID 数组,用于过滤市场(如 NBA 的 tag ID) * @param active 是否只返回活跃的市场 @@ -37,6 +38,7 @@ interface PolymarketGammaApi { @GET("/markets") suspend fun listMarkets( @Query("condition_ids") conditionIds: List? = null, + @Query("clob_token_ids") clobTokenIds: List? = null, @Query("include_tag") includeTag: Boolean? = null, @Query("tags") tags: List? = null, @Query("active") active: Boolean? = null, @@ -87,6 +89,9 @@ data class MarketResponse( val lastTradePrice: Double? = null, val bestBid: Double? = null, val bestAsk: Double? = null, + // 以下字段可能存在于响应中,但不在标准文档中 + val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组) + val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本) val tags: List? = null, // 市场标签列表 val sportsMarketType: String? = null, // 市场类型:moneyline, spread 等 val gameStartTime: String? = null, // 比赛开始时间(格式:2025-12-13 00:00:00+00) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/GsonConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/GsonConfig.kt new file mode 100644 index 0000000..e8ab03c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/GsonConfig.kt @@ -0,0 +1,26 @@ +package com.wrbug.polymarketbot.config + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * Gson 配置类 + * 统一配置 Gson 实例,使用 lenient 模式允许解析格式不严格的 JSON + */ +@Configuration +class GsonConfig { + + /** + * 创建 Gson Bean + * 使用 lenient 模式,允许解析格式不严格的 JSON + */ + @Bean + fun gson(): Gson { + return GsonBuilder() + .setLenient() + .create() + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt index 0bb9f49..aca267d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt @@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor( handler: Any ): Boolean { val path = request.requestURI - val method = request.method - - // 只拦截POST请求 - if (method != "POST") { + + // 只拦截 /api/** 路径 + if (!path.startsWith("/api/")) { return true } - + // 排除不需要鉴权的路径 if (excludePaths.contains(path)) { return true } - - // 只拦截 /api/** 路径 - if (!path.startsWith("/api/")) { + + // 允许 OPTIONS 请求(CORS 预检请求) + if (request.method == "OPTIONS") { return true } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt index a4e23be..514964d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt @@ -1,5 +1,6 @@ package com.wrbug.polymarketbot.config +import com.google.gson.Gson import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.util.createClient import org.springframework.beans.factory.annotation.Value @@ -18,7 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory * - 账户 API Key 在调用时动态设置,不在此处配置 */ @Configuration -class RetrofitConfig { +class RetrofitConfig( + private val gson: Gson +) { @Value("\${polymarket.clob.base-url}") private lateinit var clobBaseUrl: String @@ -37,7 +40,7 @@ class RetrofitConfig { return Retrofit.Builder() .baseUrl(clobBaseUrl) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() .create(PolymarketClobApi::class.java) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt index dceba99..61a383c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt @@ -21,10 +21,6 @@ class WebMvcConfig( // 再注册JWT认证拦截器 registry.addInterceptor(jwtAuthenticationInterceptor) .addPathPatterns("/api/**") - registry.addInterceptor(jwtAuthenticationInterceptor) - .addPathPatterns("/api/**") - registry.addInterceptor(jwtAuthenticationInterceptor) - .addPathPatterns("/api/**") } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt index e641741..247ac0c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt @@ -1,6 +1,7 @@ package com.wrbug.polymarketbot.config import com.wrbug.polymarketbot.repository.UserRepository +import com.wrbug.polymarketbot.service.auth.WebSocketTicketService import com.wrbug.polymarketbot.util.JwtUtils import org.slf4j.LoggerFactory import org.springframework.http.server.ServerHttpRequest @@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor /** * WebSocket 握手拦截器 - * 用于验证 JWT token + * 优先使用短期票据验证,其次使用 JWT token */ @Component class WebSocketAuthInterceptor( private val jwtUtils: JwtUtils, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val webSocketTicketService: WebSocketTicketService ) : HandshakeInterceptor { private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java) @@ -27,22 +29,36 @@ class WebSocketAuthInterceptor( wsHandler: WebSocketHandler, attributes: MutableMap ): Boolean { - // 从查询参数或请求头获取 token - val token = getTokenFromRequest(request) - - if (token == null) { - logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}") + // 优先使用票据验证(推荐方式,不暴露 JWT) + val ticket = getTicketFromRequest(request) + if (ticket != null) { + val username = webSocketTicketService.validateAndConsumeTicket(ticket) + if (username != null) { + attributes["username"] = username + logger.debug("WebSocket 连接票据认证成功: username=$username") + return true + } + logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } - + + // 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容) + val token = getTokenFromRequest(request) + + if (token == null) { + logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}") + response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) + return false + } + // 验证 token if (!jwtUtils.validateToken(token)) { - logger.warn("WebSocket 连接 token 验证失败: ${request.uri}") + logger.warn("WebSocket 连接 token 验证失败") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } - + // 验证tokenVersion(检查token是否因密码修改而失效) val username = jwtUtils.getUsernameFromToken(token) if (username != null) { @@ -50,21 +66,21 @@ class WebSocketAuthInterceptor( if (user != null) { val tokenVersion = jwtUtils.getTokenVersionFromToken(token) if (tokenVersion == null || tokenVersion != user.tokenVersion) { - logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}") + logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } } - + // 获取用户名并存入 attributes,供后续使用 attributes["username"] = username - logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}") + logger.debug("WebSocket 连接 JWT 认证成功: username=$username") } else { - logger.warn("WebSocket 连接无法获取用户名: ${request.uri}") + logger.warn("WebSocket 连接无法获取用户名") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } - + return true } @@ -78,7 +94,22 @@ class WebSocketAuthInterceptor( } /** - * 从请求中获取 token + * 从请求中获取票据 + */ + private fun getTicketFromRequest(request: ServerHttpRequest): String? { + val queryParams = request.uri.query ?: return null + val params = queryParams.split("&") + for (param in params) { + val parts = param.split("=", limit = 2) + if (parts.size == 2 && parts[0] == "ticket") { + return parts[1] + } + } + return null + } + + /** + * 从请求中获取 token(兼容旧方式) * 支持从查询参数 token 或请求头 Authorization 获取 */ private fun getTokenFromRequest(request: ServerHttpRequest): String? { @@ -93,13 +124,13 @@ class WebSocketAuthInterceptor( } } } - + // 从请求头获取 val authHeader = request.headers.getFirst("Authorization") if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7) } - + return null } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt index d258ee6..dce9847 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt @@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration import org.springframework.web.socket.config.annotation.EnableWebSocket import org.springframework.web.socket.config.annotation.WebSocketConfigurer @@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry class WebSocketConfig( private val polymarketWebSocketHandler: PolymarketWebSocketHandler, private val unifiedWebSocketHandler: UnifiedWebSocketHandler, - private val webSocketAuthInterceptor: WebSocketAuthInterceptor + private val webSocketAuthInterceptor: WebSocketAuthInterceptor, + @Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String ) : WebSocketConfigurer { - + + /** + * 获取允许的 WebSocket 来源 + * 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名 + * 否则使用 setAllowedOriginPatterns 允许同源访问 + */ + private fun getAllowedOrigins(): Array { + return if (allowedOriginsConfig.isNotBlank()) { + allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray() + } else { + emptyArray() + } + } + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + val origins = getAllowedOrigins() + // Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流) // 注意:此端点不需要鉴权,因为它只是转发外部数据 - registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket") - .setAllowedOrigins("*") // 生产环境应该配置具体的域名 - + val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket") + if (origins.isNotEmpty()) { + polymarketHandler.setAllowedOrigins(*origins) + } else { + // 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全 + polymarketHandler.setAllowedOriginPatterns("*") + } + // 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分) - // 支持的频道:position(仓位推送)、order(订单推送,待实现)等 + // 支持的频道:position(仓位推送)、order(订单推送)等 // 需要 JWT 鉴权 - registry.addHandler(unifiedWebSocketHandler, "/ws") - .addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器 - .setAllowedOrigins("*") // 生产环境应该配置具体的域名 + val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws") + .addInterceptors(webSocketAuthInterceptor) + if (origins.isNotEmpty()) { + unifiedHandler.setAllowedOrigins(*origins) + } else { + unifiedHandler.setAllowedOriginPatterns("*") + } } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt index 44ac456..152ba85 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt @@ -95,6 +95,65 @@ class AccountController( } } + /** + * 刷新账户的代理地址 + * 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型) + */ + @PostMapping("/refresh-proxy") + fun refreshProxyAddress(@RequestBody request: AccountDetailRequest): ResponseEntity> { + return try { + if (request.accountId == null || request.accountId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource)) + } + + val result = accountService.refreshProxyAddress(request.accountId) + result.fold( + onSuccess = { account -> + ResponseEntity.ok(ApiResponse.success(account)) + }, + 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)) + } + } + + /** + * 批量刷新所有账户的代理地址 + */ + @PostMapping("/refresh-all-proxies") + fun refreshAllProxyAddresses(): ResponseEntity>> { + return try { + val result = accountService.refreshAllProxyAddresses() + result.fold( + onSuccess = { accounts -> + ResponseEntity.ok(ApiResponse.success(accounts)) + }, + onFailure = { e -> + logger.error("批量刷新代理地址失败: ${e.message}", e) + 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)) + } + } + /** * 删除账户 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt index ca53c3c..1485072 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt @@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.auth import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.auth.AuthService +import com.wrbug.polymarketbot.service.auth.WebSocketTicketService import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory import org.springframework.context.MessageSource @@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/auth") class AuthController( private val authService: AuthService, - private val messageSource: MessageSource + private val messageSource: MessageSource, + private val webSocketTicketService: WebSocketTicketService ) { private val logger = LoggerFactory.getLogger(AuthController::class.java) @@ -25,7 +27,10 @@ class AuthController( * 登录接口 */ @PostMapping("/login") - fun login(@RequestBody request: LoginRequest): ResponseEntity> { + fun login( + @RequestBody request: LoginRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { return try { if (request.username.isBlank()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource)) @@ -33,15 +38,19 @@ class AuthController( if (request.password.isBlank()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource)) } - - val result = authService.login(request.username, request.password) + + val ipAddress = getClientIpAddress(httpRequest) + val result = authService.login(request.username, request.password, ipAddress) result.fold( onSuccess = { loginResponse -> ResponseEntity.ok(ApiResponse.success(loginResponse)) }, onFailure = { e -> - logger.error("登录失败: ${e.message}", e) when (e) { + is IllegalStateException -> { + // 限速或锁定错误 + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource)) + } is IllegalArgumentException -> { if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) { ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource)) @@ -49,15 +58,36 @@ class AuthController( ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)) } } - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource)) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource)) } } ) } catch (e: Exception) { logger.error("登录异常: ${e.message}", e) - ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource)) + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource)) } } + + /** + * 获取客户端IP地址 + */ + private fun getClientIpAddress(request: HttpServletRequest): String { + var ip = request.getHeader("X-Forwarded-For") + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("X-Real-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("Proxy-Client-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.remoteAddr + } + // 处理多个IP的情况 + if (ip.contains(",")) { + ip = ip.split(",")[0].trim() + } + return ip + } /** * 重置密码接口 @@ -132,5 +162,27 @@ class AuthController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource)) } } + + /** + * 获取 WebSocket 连接票据 + * 返回一个短期有效(30秒)的一次性票据,用于 WebSocket 连接认证 + * 避免在 WebSocket URL 中暴露 JWT + */ + @PostMapping("/ws-ticket") + fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity> { + return try { + // 从请求属性中获取用户名(由 JWT 拦截器设置) + val username = httpRequest.getAttribute("username") as? String + if (username == null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource)) + } + + val ticket = webSocketTicketService.generateTicket(username) + ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket))) + } catch (e: Exception) { + logger.error("获取 WebSocket 票据异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt index 6deed2d..16ed5e2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt @@ -4,8 +4,10 @@ import com.wrbug.polymarketbot.api.LatestPriceResponse import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.accounts.AccountService +import com.wrbug.polymarketbot.service.common.MarketPriceService import com.wrbug.polymarketbot.service.common.PolymarketClobService import kotlinx.coroutines.runBlocking +import java.math.BigDecimal import org.slf4j.LoggerFactory import org.springframework.context.MessageSource import org.springframework.http.ResponseEntity @@ -20,14 +22,16 @@ import org.springframework.web.bind.annotation.* class MarketController( private val accountService: AccountService, private val clobService: PolymarketClobService, + private val marketPriceService: MarketPriceService, private val messageSource: MessageSource ) { private val logger = LoggerFactory.getLogger(MarketController::class.java) /** - * 获取市场价格(通过 Gamma API) - * 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数 + * 获取市场价格 + * 使用 MarketPriceService 获取当前市场价格(支持多数据源降级) + * 返回当前价格,前端接收后自行填充到 bestBid 字段 */ @PostMapping("/price") fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity> { @@ -36,16 +40,16 @@ class MarketController( return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource)) } - val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) } - result.fold( - onSuccess = { response -> - ResponseEntity.ok(ApiResponse.success(response)) - }, - onFailure = { e -> - logger.error("获取市场价格失败: ${e.message}", e) - ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource)) - } + val outcomeIndex = request.outcomeIndex ?: 0 + val price = runBlocking { + marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex) + } + + val response = MarketPriceResponse( + marketId = request.marketId, + currentPrice = price.toString() ) + ResponseEntity.ok(ApiResponse.success(response)) } catch (e: Exception) { logger.error("获取市场价格异常: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource)) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/RpcNodeController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/RpcNodeController.kt new file mode 100644 index 0000000..3d202b6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/RpcNodeController.kt @@ -0,0 +1,366 @@ +package com.wrbug.polymarketbot.controller.system + +import com.wrbug.polymarketbot.dto.ApiResponse +import com.wrbug.polymarketbot.entity.RpcNodeConfig +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.system.AddRpcNodeRequest +import com.wrbug.polymarketbot.service.system.NodeCheckResult +import com.wrbug.polymarketbot.service.system.RpcNodeService +import com.wrbug.polymarketbot.service.system.UpdateRpcNodeRequest +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * RPC 节点管理控制器 + */ +@RestController +@RequestMapping("/api/system/rpc-nodes") +class RpcNodeController( + private val rpcNodeService: RpcNodeService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(RpcNodeController::class.java) + + /** + * 获取所有节点列表 + */ + @PostMapping("/list") + fun getAllNodes(@RequestBody request: Map?): ResponseEntity>> { + return try { + val nodes = rpcNodeService.getAllNodes() + val dtos = nodes.map { it.toDto() } + ResponseEntity.ok(ApiResponse.success(dtos)) + } catch (e: Exception) { + logger.error("获取 RPC 节点列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "获取节点列表失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 添加节点 + */ + @PostMapping("/add") + fun addNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity> { + return try { + if (request.providerType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空")) + } + if (request.name.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空")) + } + + val result = rpcNodeService.addNode(request) + + result.fold( + onSuccess = { node -> + ResponseEntity.ok(ApiResponse.success(node.toDto())) + }, + onFailure = { e -> + logger.error("添加 RPC 节点失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "添加节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("添加 RPC 节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "添加节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 更新节点 + */ + @PostMapping("/update") + fun updateNode(@RequestBody request: UpdateRpcNodeRequest): ResponseEntity> { + return try { + val result = rpcNodeService.updateNode(request) + + result.fold( + onSuccess = { node -> + ResponseEntity.ok(ApiResponse.success(node.toDto())) + }, + onFailure = { e -> + logger.error("更新 RPC 节点失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("更新 RPC 节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 删除节点 + */ + @PostMapping("/delete") + fun deleteNode(@RequestBody request: DeleteRpcNodeRequest): ResponseEntity> { + return try { + val result = rpcNodeService.deleteNode(request.id) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除 RPC 节点失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "删除节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("删除 RPC 节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "删除节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 更新节点优先级 + */ + @PostMapping("/update-priority") + fun updatePriority(@RequestBody request: UpdatePriorityRequest): ResponseEntity> { + return try { + val result = rpcNodeService.updatePriority(request.id, request.priority) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("更新节点优先级失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新优先级失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("更新节点优先级异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新优先级失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 检查节点健康状态 + */ + @PostMapping("/check-health") + fun checkHealth(@RequestBody request: CheckHealthRequest): ResponseEntity> { + return try { + if (request.id != null) { + // 检查单个节点 + val result = rpcNodeService.checkNodeHealth(request.id) + result.fold( + onSuccess = { checkResult -> + ResponseEntity.ok(ApiResponse.success(checkResult.toDto())) + }, + onFailure = { e -> + logger.error("检查节点健康状态失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "检查节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } else { + // 批量检查所有节点 + val result = rpcNodeService.checkAllNodesHealth() + result.fold( + onSuccess = { checkResults -> + val dtos = checkResults.mapValues { it.value.toDto() } + ResponseEntity.ok(ApiResponse.success(dtos)) + }, + onFailure = { e -> + logger.error("批量检查节点健康状态失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "批量检查节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } + } catch (e: Exception) { + logger.error("检查节点健康状态异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "检查节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 校验节点(添加前) + */ + @PostMapping("/validate") + fun validateNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity> { + return try { + if (request.providerType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空")) + } + if (request.name.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空")) + } + + // 临时创建节点配置以进行验证(不保存到数据库) + // 这里直接复用 addNode 的部分逻辑,但只进行校验 + val result = rpcNodeService.addNode(request) + + result.fold( + onSuccess = { node -> + // 添加成功后立即删除(这只是为了校验) + rpcNodeService.deleteNode(node.id!!) + ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse( + valid = true, + message = "节点可用", + responseTimeMs = node.responseTimeMs + ))) + }, + onFailure = { e -> + ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse( + valid = false, + message = e.message ?: "节点验证失败", + responseTimeMs = null + ))) + } + ) + } catch (e: Exception) { + logger.error("验证节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse( + valid = false, + message = e.message ?: "节点验证失败", + responseTimeMs = null + ))) + } + } +} + +/** + * RPC 节点配置 DTO + */ +data class RpcNodeConfigDto( + val id: Long?, + val providerType: String, + val name: String, + val httpUrl: String, + val wsUrl: String?, + val apiKeyMasked: String?, // 脱敏后的 API Key + val enabled: Boolean, + val priority: Int, + val lastCheckTime: Long?, + val lastCheckStatus: String?, + val responseTimeMs: Int?, + val createdAt: Long, + val updatedAt: Long +) + +/** + * 节点检查结果 DTO + */ +data class NodeCheckResultDto( + val status: String, + val message: String, + val checkTime: Long, + val responseTimeMs: Int?, + val blockNumber: String? +) + +/** + * 验证节点响应 + */ +data class ValidateNodeResponse( + val valid: Boolean, + val message: String, + val responseTimeMs: Int? +) + +/** + * 删除节点请求 + */ +data class DeleteRpcNodeRequest( + val id: Long +) + +/** + * 更新优先级请求 + */ +data class UpdatePriorityRequest( + val id: Long, + val priority: Int +) + +/** + * 检查健康状态请求 + */ +data class CheckHealthRequest( + val id: Long? = null // 如果为 null,则检查所有节点 +) + +/** + * 扩展函数:将 RpcNodeConfig 转换为 DTO + */ +private fun RpcNodeConfig.toDto(): RpcNodeConfigDto { + return RpcNodeConfigDto( + id = id, + providerType = providerType, + name = name, + httpUrl = httpUrl, + wsUrl = wsUrl, + apiKeyMasked = apiKey?.let { "***" }, // 脱敏显示 + enabled = enabled, + priority = priority, + lastCheckTime = lastCheckTime, + lastCheckStatus = lastCheckStatus, + responseTimeMs = responseTimeMs, + createdAt = createdAt, + updatedAt = updatedAt + ) +} + +/** + * 扩展函数:将 NodeCheckResult 转换为 DTO + */ +private fun NodeCheckResult.toDto(): NodeCheckResultDto { + return NodeCheckResultDto( + status = status.name, + message = message, + checkTime = checkTime, + responseTimeMs = responseTimeMs, + blockNumber = blockNumber + ) +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index d7130d3..193f0ca 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -7,7 +7,8 @@ data class AccountImportRequest( val privateKey: String, // 私钥(前端加密后传输) val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证) val accountName: String? = null, - val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关) + val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关) + val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包) ) /** @@ -72,6 +73,7 @@ data class AccountDto( val proxyAddress: String, // Polymarket 代理钱包地址 val accountName: String?, val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关) + val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包) val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key) val apiSecretConfigured: Boolean, // API Secret 是否已配置 val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置 @@ -195,14 +197,11 @@ data class LatestPriceRequest( ) /** - * 市场价格响应 + * 市场当前价格响应 */ data class MarketPriceResponse( val marketId: String, - val lastPrice: String?, // 最新成交价 - val bestBid: String?, // 最优买价(用于卖出参考) - val bestAsk: String?, // 最优卖价(用于买入参考) - val midpoint: String? // 中间价 + val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级) ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt index 213553b..7c3eb38 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt @@ -14,3 +14,10 @@ data class CheckFirstUseResponse( val isFirstUse: Boolean ) +/** + * WebSocket 票据响应 + */ +data class WebSocketTicketResponse( + val ticket: String +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt index aa9466b..05220ff 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt @@ -34,6 +34,9 @@ data class CopyTradingCreateRequest( val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 // 新增配置字段 val configName: String? = null, // 配置名(可选) val pushFailedOrders: Boolean? = null // 推送失败订单(可选) @@ -65,6 +68,9 @@ data class CopyTradingUpdateRequest( val maxSpread: String? = null, val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 // 新增配置字段 val configName: String? = null, // 配置名(可选,但提供时必须非空) val pushFailedOrders: Boolean? = null // 推送失败订单(可选) @@ -133,6 +139,9 @@ data class CopyTradingDto( val maxSpread: String?, val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 // 新增配置字段 val configName: String? = null, // 配置名(可选) val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt index fd5056f..d0cd8d1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt @@ -40,6 +40,9 @@ data class Account( @Column(name = "is_enabled", nullable = false) val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关) + @Column(name = "wallet_type", nullable = false, length = 20) + val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包) + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt index 10bdd14..be0224b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt @@ -38,6 +38,9 @@ data class CopyOrderTracking( @Column(name = "leader_buy_trade_id", nullable = false, length = 100) val leaderBuyTradeId: String, // Leader 买入交易ID + @Column(name = "leader_buy_quantity", nullable = true, precision = 20, scale = 8) + val leaderBuyQuantity: BigDecimal? = null, // Leader 买入数量(用于固定金额模式计算卖出比例) + @Column(name = "quantity", nullable = false, precision = 20, scale = 8) val quantity: BigDecimal, // 买入数量 @@ -53,6 +56,9 @@ data class CopyOrderTracking( @Column(name = "status", nullable = false, length = 20) var status: String = "filled", // filled, fully_matched, partially_matched + @Column(name = "notification_sent", nullable = false) + var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送) + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt index 0fa1922..ec2aa46 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt @@ -84,6 +84,13 @@ data class CopyTrading( @Column(name = "max_price", precision = 20, scale = 8) val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + @Column(name = "max_position_value", precision = 20, scale = 8) + val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用 + + @Column(name = "max_position_count") + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 + // 新增配置字段 @Column(name = "config_name", length = 255) val configName: String? = null, // 配置名(可选) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt deleted file mode 100644 index 4accb7d..0000000 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FailedTrade.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.wrbug.polymarketbot.entity - -import jakarta.persistence.* - -/** - * 失败交易实体 - * 记录处理失败的交易信息 - */ -@Entity -@Table(name = "failed_trade") -data class FailedTrade( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long? = null, - - @Column(name = "leader_id", nullable = false) - val leaderId: Long, - - @Column(name = "leader_trade_id", nullable = false, length = 100) - val leaderTradeId: String, // Leader 的交易ID - - @Column(name = "trade_type", nullable = false, length = 10) - val tradeType: String, // BUY 或 SELL - - @Column(name = "copy_trading_id", nullable = false) - val copyTradingId: Long, - - @Column(name = "account_id", nullable = false) - val accountId: Long, - - @Column(name = "market_id", nullable = false, length = 100) - val marketId: String, - - @Column(name = "side", nullable = false, length = 10) - val side: String, // YES/NO - - @Column(name = "price", nullable = false, length = 50) - val price: String, // 价格(字符串格式) - - @Column(name = "size", nullable = false, length = 50) - val size: String, // 数量(字符串格式) - - @Column(name = "error_message", columnDefinition = "TEXT") - val errorMessage: String? = null, // 错误信息 - - @Column(name = "retry_count", nullable = false) - val retryCount: Int = 0, // 重试次数 - - @Column(name = "failed_at", nullable = false) - val failedAt: Long = System.currentTimeMillis(), - - @Column(name = "created_at", nullable = false) - val createdAt: Long = System.currentTimeMillis() -) - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/RpcNodeConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/RpcNodeConfig.kt new file mode 100644 index 0000000..8443207 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/RpcNodeConfig.kt @@ -0,0 +1,73 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * Polygon RPC 节点配置实体 + * 用于存储用户配置的 RPC 节点信息 + */ +@Entity +@Table(name = "rpc_node_config") +data class RpcNodeConfig( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "provider_type", nullable = false, length = 50) + val providerType: String, // 服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC + + @Column(name = "name", nullable = false, length = 100) + val name: String, // 节点名称 + + @Column(name = "http_url", nullable = false, length = 500) + val httpUrl: String, // HTTP RPC URL + + @Column(name = "ws_url", length = 500) + val wsUrl: String? = null, // WebSocket URL (可选) + + @Column(name = "api_key", length = 200) + val apiKey: String? = null, // API Key (加密存储) + + @Column(name = "enabled", nullable = false) + var enabled: Boolean = true, // 是否启用 + + @Column(name = "priority", nullable = false) + var priority: Int = 0, // 优先级(数字越小优先级越高) + + @Column(name = "last_check_time") + var lastCheckTime: Long? = null, // 最后检查时间(毫秒时间戳) + + @Column(name = "last_check_status", length = 20) + var lastCheckStatus: String? = null, // 最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN + + @Column(name = "response_time_ms") + var responseTimeMs: Int? = null, // 最后一次响应时间(毫秒) + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + +/** + * RPC 节点健康状态枚举 + */ +enum class NodeHealthStatus { + HEALTHY, // 健康 + UNHEALTHY, // 不健康 + UNKNOWN // 未知 +} + +/** + * RPC 节点服务商类型枚举 + */ +enum class RpcProviderType { + ALCHEMY, + INFURA, + QUICKNODE, + CHAINSTACK, + GETBLOCK, + CUSTOM, + PUBLIC +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt index 048905c..9afbcba 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt @@ -41,6 +41,9 @@ data class SellMatchRecord( @Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8) val totalRealizedPnl: BigDecimal, // 总已实现盈亏 + @Column(name = "price_updated", nullable = false) + var priceUpdated: Boolean = false, // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知) + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis() ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt index 7b6ef07..84d1bae 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt @@ -50,5 +50,16 @@ interface CopyOrderTrackingRepository : JpaRepository { * 根据买入订单ID查询订单跟踪记录 */ fun findByBuyOrderId(buyOrderId: String): List + + /** + * 查询未发送通知的买入订单(用于轮询更新) + */ + fun findByNotificationSentFalse(): List + + /** + * 查询指定时间之前创建的订单(用于检查30秒后未成交的订单) + */ + @Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime") + fun findByCreatedAtBefore(beforeTime: Long): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt deleted file mode 100644 index e99974b..0000000 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FailedTradeRepository.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.wrbug.polymarketbot.repository - -import com.wrbug.polymarketbot.entity.FailedTrade -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.stereotype.Repository - -/** - * 失败交易Repository - */ -@Repository -interface FailedTradeRepository : JpaRepository { - - /** - * 根据Leader ID和交易ID查询 - */ - fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade? - - /** - * 检查是否存在失败的交易 - */ - fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean -} - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/RpcNodeConfigRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/RpcNodeConfigRepository.kt new file mode 100644 index 0000000..c36f5c9 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/RpcNodeConfigRepository.kt @@ -0,0 +1,23 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.RpcNodeConfig +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface RpcNodeConfigRepository : JpaRepository { + /** + * 查询所有已启用的节点,按优先级排序(优先级数字越小越靠前) + */ + fun findAllByEnabledTrueOrderByPriorityAsc(): List + + /** + * 查询指定 ID 的已启用节点 + */ + fun findByIdAndEnabledTrue(id: Long): RpcNodeConfig? + + /** + * 查询所有节点,按优先级排序 + */ + fun findAllByOrderByPriorityAsc(): List +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt index 7b8cb17..51131d1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt @@ -24,5 +24,11 @@ interface SellMatchRecordRepository : JpaRepository { * 根据Leader卖出交易ID查询记录 */ fun findByLeaderSellTradeId(leaderSellTradeId: String): SellMatchRecord? + + /** + * 查询所有价格未更新的卖出记录 + * 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段) + */ + fun findByPriceUpdatedFalse(): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index f7c4bc1..e60300e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -37,7 +37,8 @@ class AccountService( private val orderSigningService: OrderSigningService, private val cryptoUtils: CryptoUtils, private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖 - private val relayClientService: RelayClientService + private val relayClientService: RelayClientService, + private val jsonUtils: JsonUtils ) { private val logger = LoggerFactory.getLogger(AccountService::class.java) @@ -98,8 +99,9 @@ class AccountService( } // 5. 获取代理地址(必须成功,否则导入失败) + // 根据用户选择的钱包类型计算代理地址 val proxyAddress = runBlocking { - val proxyResult = blockchainService.getProxyAddress(request.walletAddress) + val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType) if (proxyResult.isSuccess) { val address = proxyResult.getOrNull() if (address != null) { @@ -149,6 +151,7 @@ class AccountService( accountName = accountName, isDefault = false, // 不再支持默认账户 isEnabled = request.isEnabled, + walletType = request.walletType, // 保存钱包类型 createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis() ) @@ -199,6 +202,85 @@ class AccountService( } } + /** + * 刷新账户的代理地址 + * 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型) + */ + @Transactional + fun refreshProxyAddress(accountId: Long): Result { + return try { + val account = accountRepository.findById(accountId) + .orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 重新获取代理地址(使用保存的钱包类型) + val proxyAddress = runBlocking { + val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType) + if (proxyResult.isSuccess) { + proxyResult.getOrNull() + ?: throw IllegalStateException("获取代理地址返回空值") + } else { + val error = proxyResult.exceptionOrNull() + throw IllegalStateException("获取代理地址失败: ${error?.message}") + } + } + + // 更新账户 + val updated = account.copy( + proxyAddress = proxyAddress, + updatedAt = System.currentTimeMillis() + ) + val saved = accountRepository.save(updated) + + logger.info("刷新代理地址成功: accountId=${accountId}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}") + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("刷新代理地址失败: accountId=${accountId}", e) + Result.failure(e) + } + } + + /** + * 刷新所有账户的代理地址 + */ + @Transactional + fun refreshAllProxyAddresses(): Result> { + return try { + val accounts = accountRepository.findAll() + val updatedAccounts = mutableListOf() + + accounts.forEach { account -> + try { + val proxyAddress = runBlocking { + val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType) + if (proxyResult.isSuccess) { + proxyResult.getOrNull() + } else { + null + } + } + + if (proxyAddress != null && proxyAddress != account.proxyAddress) { + val updated = account.copy( + proxyAddress = proxyAddress, + updatedAt = System.currentTimeMillis() + ) + val saved = accountRepository.save(updated) + logger.info("刷新代理地址成功: accountId=${account.id}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}") + updatedAccounts.add(toDto(saved)) + } + } catch (e: Exception) { + logger.warn("刷新账户 ${account.id} 代理地址失败: ${e.message}") + } + } + + logger.info("批量刷新代理地址完成: 更新了 ${updatedAccounts.size} 个账户") + Result.success(updatedAccounts) + } catch (e: Exception) { + logger.error("批量刷新代理地址失败", e) + Result.failure(e) + } + } + /** * 删除账户 */ @@ -362,6 +444,7 @@ class AccountService( proxyAddress = account.proxyAddress, accountName = account.accountName, isEnabled = account.isEnabled, + walletType = account.walletType, apiKeyConfigured = account.apiKey != null, apiSecretConfigured = account.apiSecret != null, apiPassphraseConfigured = account.apiPassphrase != null, @@ -927,6 +1010,8 @@ class AccountService( marketId = request.marketId, marketSlug = marketSlug, side = request.side, + price = sellPrice, // 直接传递卖出价格 + size = sellQuantity.toPlainString(), // 直接传递卖出数量 accountName = account.accountName, walletAddress = account.walletAddress, clobApi = clobApi, @@ -1119,7 +1204,7 @@ class AccountService( // 如果目标 outcome 不是第一个(index != 0),需要转换价格 // 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格 if (outcomeIndex != null && outcomeIndex > 0) { - val outcomes = JsonUtils.parseStringArray(market.outcomes) + val outcomes = jsonUtils.parseStringArray(market.outcomes) // 只对二元市场进行价格转换 if (outcomes.size == 2) { // 保存原始第一个 outcome 的价格 @@ -1151,13 +1236,13 @@ class AccountService( null } + // 优先使用 lastPrice(最近成交价),如果没有则使用 bestBid,最后使用 midpoint + val currentPrice = lastPrice ?: bestBid ?: midpoint ?: "0" + Result.success( MarketPriceResponse( marketId = marketId, - lastPrice = lastPrice, - bestBid = bestBid, - bestAsk = bestAsk, - midpoint = midpoint + currentPrice = currentPrice ) ) } else { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt index 6fc85ac..b33c395 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt @@ -21,8 +21,7 @@ import org.springframework.context.i18n.LocaleContextHolder import com.wrbug.polymarketbot.service.system.SystemConfigService import com.wrbug.polymarketbot.service.system.RelayClientService import com.wrbug.polymarketbot.service.system.TelegramNotificationService -import com.wrbug.polymarketbot.util.RetrofitFactory -import com.wrbug.polymarketbot.util.JsonUtils +import com.wrbug.polymarketbot.service.common.MarketPriceService import org.springframework.stereotype.Service import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap @@ -45,7 +44,7 @@ class PositionCheckService( private val telegramNotificationService: TelegramNotificationService?, private val accountRepository: AccountRepository, private val messageSource: MessageSource, - private val retrofitFactory: RetrofitFactory + private val marketPriceService: MarketPriceService ) { private val logger = LoggerFactory.getLogger(PositionCheckService::class.java) @@ -427,118 +426,10 @@ class PositionCheckService( /** * 获取当前市场最新价(用于更新订单卖出价) - * 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价) - * 如果市场已关闭: - * - 该 outcome 赢了,返回 1 - * - 该 outcome 输了,返回 0 + * 委托给 MarketPriceService 处理 */ private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal { - return try { - // 先获取市场信息,检查市场是否已关闭 - val gammaApi = retrofitFactory.createGammaApi() - val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId)) - - if (marketResponse.isSuccessful && marketResponse.body() != null) { - val markets = marketResponse.body()!! - val market = markets.firstOrNull() - - if (market != null && market.closed == true) { - // 市场已关闭,检查该 outcome 是赢了还是输了 - val outcomeResult = checkOutcomeResult(market, outcomeIndex) - when (outcomeResult) { - OutcomeResult.WON -> { - logger.info("市场已关闭且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex") - return BigDecimal.ONE - } - OutcomeResult.LOST -> { - logger.info("市场已关闭且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex") - return BigDecimal.ZERO - } - OutcomeResult.UNKNOWN -> { - // 无法判断,继续使用正常价格逻辑 - } - } - } - } - - // 如果市场未关闭或无法判断输赢,获取正常价格 - val priceResult = accountService.getMarketPrice(marketId, outcomeIndex) - val marketPrice = priceResult.getOrNull() - if (marketPrice != null) { - // 优先使用 bestBid(最优买价,用于卖出参考),如果没有则使用 midpoint - val priceStr = marketPrice.bestBid ?: marketPrice.midpoint ?: marketPrice.lastPrice - priceStr?.toSafeBigDecimal() ?: BigDecimal.ZERO - } else { - BigDecimal.ZERO - } - } catch (e: Exception) { - logger.error("获取市场最新价失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e) - BigDecimal.ZERO - } - } - - /** - * Outcome 结果枚举 - */ - private enum class OutcomeResult { - WON, // 赢了 - LOST, // 输了 - UNKNOWN // 无法判断 - } - - /** - * 检查该 outcome 的结果(赢了、输了或无法判断) - * @param market 市场信息 - * @param outcomeIndex outcome 索引 - * @return OutcomeResult - */ - private fun checkOutcomeResult(market: com.wrbug.polymarketbot.api.MarketResponse, outcomeIndex: Int): OutcomeResult { - return try { - // 优先使用 outcomePrices(结算价格数组) - val outcomePrices = market.outcomePrices - if (outcomePrices != null && outcomePrices.isNotBlank()) { - val prices = JsonUtils.parseStringArray(outcomePrices) - if (outcomeIndex < prices.size) { - val price = prices[outcomeIndex].toSafeBigDecimal() - // 如果价格 >= 0.99,认为赢了 - if (price >= BigDecimal("0.99")) { - return OutcomeResult.WON - } - // 如果价格 <= 0.01,认为输了 - if (price <= BigDecimal("0.01")) { - return OutcomeResult.LOST - } - // 其他情况,无法判断 - return OutcomeResult.UNKNOWN - } - } - - // 如果没有 outcomePrices,使用 bestBid 和 bestAsk 判断 - val bestBid = market.bestBid ?: 0.0 - val bestAsk = market.bestAsk ?: 0.0 - - // 如果目标 outcome 不是第一个(index != 0),需要转换价格 - val targetBid = if (outcomeIndex > 0) { - // 第二个 outcome 的 bestBid = 1 - 第一个 outcome 的 bestAsk - BigDecimal.ONE.subtract(BigDecimal.valueOf(bestAsk)) - } else { - BigDecimal.valueOf(bestBid) - } - - // 如果 bestBid >= 0.99,认为赢了 - if (targetBid >= BigDecimal("0.99")) { - return OutcomeResult.WON - } - // 如果 bestBid <= 0.01,认为输了 - if (targetBid <= BigDecimal("0.01")) { - return OutcomeResult.LOST - } - // 其他情况,无法判断 - OutcomeResult.UNKNOWN - } catch (e: Exception) { - logger.warn("检查 outcome 结果失败: marketId=${market.conditionId}, outcomeIndex=$outcomeIndex, error=${e.message}", e) - OutcomeResult.UNKNOWN - } + return marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex) } @@ -628,7 +519,8 @@ class PositionCheckService( outcomeIndex = outcomeIndex, totalMatchedQuantity = totalMatchedQuantity, sellPrice = sellPrice, - totalRealizedPnl = totalRealizedPnl + totalRealizedPnl = totalRealizedPnl, + priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知 ) val savedRecord = sellMatchRecordRepository.save(matchRecord) @@ -742,7 +634,8 @@ class PositionCheckService( outcomeIndex = outcomeIndex, totalMatchedQuantity = totalMatchedQuantity, sellPrice = sellPrice, - totalRealizedPnl = totalRealizedPnl + totalRealizedPnl = totalRealizedPnl, + priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知 ) val savedRecord = sellMatchRecordRepository.save(matchRecord) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt index cd0a880..c0ffb19 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt @@ -31,22 +31,44 @@ class AuthService( private lateinit var resetPasswordKey: String /** - * 登录 + * 登录(带IP限速保护) */ - fun login(username: String, password: String): Result { + fun login(username: String, password: String, ipAddress: String): Result { return try { + // 检查登录频率限制 + rateLimitService.checkLoginRateLimit(ipAddress).fold( + onSuccess = { }, + onFailure = { e -> + return Result.failure(IllegalStateException(e.message ?: "登录频率限制")) + } + ) + val user = userRepository.findByUsername(username) - ?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) - - // 验证密码 - if (!passwordEncoder.matches(password, user.password)) { - logger.warn("登录失败:密码错误,username=$username") + if (user == null) { + // 记录失败尝试 + val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress) + if (lockoutMsg != null) { + return Result.failure(IllegalStateException(lockoutMsg)) + } return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) } - + + // 验证密码 + if (!passwordEncoder.matches(password, user.password)) { + // 记录失败尝试 + val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress) + if (lockoutMsg != null) { + return Result.failure(IllegalStateException(lockoutMsg)) + } + return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) + } + + // 登录成功,清除失败记录 + rateLimitService.clearLoginFailures(ipAddress) + // 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效) val token = jwtUtils.generateToken(username, user.tokenVersion) - + logger.info("用户登录成功:username=$username") Result.success(LoginResponse(token = token)) } catch (e: Exception) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/WebSocketTicketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/WebSocketTicketService.kt new file mode 100644 index 0000000..12d6c06 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/WebSocketTicketService.kt @@ -0,0 +1,93 @@ +package com.wrbug.polymarketbot.service.auth + +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap + +/** + * WebSocket 票据服务 + * 用于生成短期有效的一次性票据,避免在 WebSocket URL 中暴露 JWT + */ +@Service +class WebSocketTicketService { + + companion object { + // 票据有效期(30秒) + private const val TICKET_VALIDITY_MS = 30_000L + + // 票据长度(32字节 = 64个十六进制字符) + private const val TICKET_LENGTH = 32 + } + + private val secureRandom = SecureRandom() + + // 存储票据:ticket -> TicketInfo + private val tickets = ConcurrentHashMap() + + /** + * 票据信息 + */ + data class TicketInfo( + val username: String, + val createdAt: Long, + val expiresAt: Long + ) + + /** + * 为用户生成 WebSocket 连接票据 + * @param username 用户名 + * @return 一次性票据 + */ + fun generateTicket(username: String): String { + // 清理过期票据 + cleanupExpiredTickets() + + // 生成随机票据 + val bytes = ByteArray(TICKET_LENGTH) + secureRandom.nextBytes(bytes) + val ticket = bytes.joinToString("") { "%02x".format(it) } + + val now = System.currentTimeMillis() + tickets[ticket] = TicketInfo( + username = username, + createdAt = now, + expiresAt = now + TICKET_VALIDITY_MS + ) + + return ticket + } + + /** + * 验证并消费票据(一次性使用) + * @param ticket 票据 + * @return 用户名,如果票据无效则返回 null + */ + fun validateAndConsumeTicket(ticket: String): String? { + val ticketInfo = tickets.remove(ticket) ?: return null + + // 检查是否过期 + if (System.currentTimeMillis() > ticketInfo.expiresAt) { + return null + } + + return ticketInfo.username + } + + /** + * 清理过期票据 + */ + private fun cleanupExpiredTickets() { + val now = System.currentTimeMillis() + tickets.entries.removeIf { it.value.expiresAt < now } + } + + /** + * 定时清理过期票据(每分钟执行一次) + * 防止过期票据长时间占用内存 + */ + @Scheduled(fixedRate = 60_000) // 60秒 = 60000毫秒 + fun scheduledCleanup() { + cleanupExpiredTickets() + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index d3513a7..2fd679e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -1,5 +1,6 @@ package com.wrbug.polymarketbot.service.common +import com.google.gson.Gson import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.JsonRpcRequest import com.wrbug.polymarketbot.api.JsonRpcResponse @@ -12,6 +13,7 @@ import com.wrbug.polymarketbot.util.createClient import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import com.wrbug.polymarketbot.service.system.RelayClientService +import com.wrbug.polymarketbot.service.system.RpcNodeService import org.springframework.stereotype.Service import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -26,10 +28,10 @@ import java.math.BigInteger class BlockchainService( @Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}") private val dataApiBaseUrl: String, - @Value("\${polygon.rpc.url:}") - private val polygonRpcUrl: String, private val retrofitFactory: RetrofitFactory, - private val relayClientService: RelayClientService + private val relayClientService: RelayClientService, + private val rpcNodeService: RpcNodeService, + private val gson: Gson ) { private val logger = LoggerFactory.getLogger(BlockchainService::class.java) @@ -37,9 +39,16 @@ class BlockchainService( // USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon) private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" - // Polymarket 代理工厂合约地址(Polygon 主网) + // Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户) // 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b - private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + private val safeProxyFactoryAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + + // Polymarket Magic 代理工厂合约地址(Polygon 主网,用于邮箱/OAuth 登录用户) + // 合约地址: 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052 + private val magicProxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052" + + // Magic Proxy 的 init code hash(用于 CREATE2 计算) + private val magicProxyInitCodeHash = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b" // ConditionalTokens 合约地址(Polygon 主网) private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" @@ -64,79 +73,174 @@ class BlockchainService( Retrofit.Builder() .baseUrl("$baseUrl/") .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() .create(PolymarketDataApi::class.java) } - private val polygonRpcApi: EthereumRpcApi? by lazy { - if (polygonRpcUrl.isBlank()) { - null - } else { - retrofitFactory.createEthereumRpcApi(polygonRpcUrl) - } + private val polygonRpcApi: EthereumRpcApi by lazy { + val rpcUrl = rpcNodeService.getHttpUrl() + retrofitFactory.createEthereumRpcApi(rpcUrl) } /** * 获取 Polymarket 代理钱包地址 - * 通过 RPC 调用代理工厂合约获取用户的代理钱包地址 + * 根据指定的钱包类型返回对应的代理地址 + * + * Polymarket 有两种代理钱包类型: + * 1. Magic Proxy(邮箱/OAuth 登录用户)- 使用 CREATE2 计算地址 + * 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址 + * + * @param walletAddress 用户的钱包地址(EOA) + * @param walletType 钱包类型:"magic"(默认)或 "safe" + * @return 代理钱包地址 + */ + suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result { + return try { + when (walletType.lowercase()) { + "safe" -> { + // Safe Proxy(MetaMask 用户) + val safeProxyResult = getSafeProxyAddress(walletAddress) + if (safeProxyResult.isSuccess) { + val safeProxyAddress = safeProxyResult.getOrNull()!! + logger.debug("使用 Safe Proxy 地址: $safeProxyAddress") + Result.success(safeProxyAddress) + } else { + Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败")) + } + } + else -> { + // Magic Proxy(邮箱/OAuth 登录用户)- 默认 + val magicProxyAddress = calculateMagicProxyAddress(walletAddress) + logger.debug("使用 Magic Proxy 地址: $magicProxyAddress") + Result.success(magicProxyAddress) + } + } + } catch (e: Exception) { + logger.error("获取代理地址失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 计算 Magic Proxy 地址(使用 CREATE2) + * 用于邮箱/OAuth 登录的用户 + * + * CREATE2 地址计算公式: + * address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:] + * salt = keccak256(eoaAddress) + * + * @param walletAddress 用户的钱包地址(EOA) + * @return Magic 代理钱包地址 + */ + fun calculateMagicProxyAddress(walletAddress: String): String { + // 计算 salt = keccak256(eoaAddress) + val eoaBytes = EthereumUtils.hexToBytes(walletAddress.lowercase()) + val salt = EthereumUtils.keccak256(eoaBytes) + + // 计算 CREATE2 地址 + // data = 0xff ++ factory ++ salt ++ initCodeHash + val prefix = byteArrayOf(0xff.toByte()) + val factoryBytes = EthereumUtils.hexToBytes(magicProxyFactoryAddress) + val initCodeHashBytes = EthereumUtils.hexToBytes(magicProxyInitCodeHash) + + val data = prefix + factoryBytes + salt + initCodeHashBytes + val hash = EthereumUtils.keccak256(data) + + // 取后 20 字节作为地址 + return "0x" + hash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) } + } + + /** + * 获取 Safe Proxy 地址 + * 通过 RPC 调用 Safe 代理工厂合约获取用户的代理钱包地址 + * 用于 MetaMask 钱包用户 + * * @param walletAddress 用户的钱包地址 * @return 代理钱包地址 */ - suspend fun getProxyAddress(walletAddress: String): Result { + private suspend fun getSafeProxyAddress(walletAddress: String): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法获取代理地址") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法获取代理地址。请在配置文件中设置 polygon.rpc.url 环境变量")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") - + val rpcApi = polygonRpcApi + // 计算函数选择器 val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature) // 编码地址参数 val encodedAddress = EthereumUtils.encodeAddress(walletAddress) // 构建调用数据 val data = functionSelector + encodedAddress - + // 构建 JSON-RPC 请求 val rpcRequest = JsonRpcRequest( method = "eth_call", params = listOf( mapOf( - "to" to proxyFactoryContractAddress, + "to" to safeProxyFactoryAddress, "data" to data ), "latest" ) ) - + // 发送 RPC 请求 val response = rpcApi.call(rpcRequest) - + if (!response.isSuccessful || response.body() == null) { - throw Exception("RPC 请求失败: ${response.code()} ${response.message()}") + return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}")) } - + val rpcResponse = response.body()!! - + // 检查错误 if (rpcResponse.error != null) { - throw Exception("RPC 错误: ${rpcResponse.error.message}") + return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}")) } - - val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空") - + + // 使用 Gson 解析 result(JsonElement) + val hexResult = rpcResponse.result?.asString + ?: return Result.failure(Exception("RPC 响应格式错误: result 为空")) + // 解析代理地址 val proxyAddress = EthereumUtils.decodeAddress(hexResult) - + Result.success(proxyAddress) } catch (e: Exception) { - logger.error("获取代理地址失败: ${e.message}", e) Result.failure(e) } } + + /** + * 检查地址是否是合约 + * @param address 地址 + * @return 如果地址有代码(是合约)返回 true + */ + private suspend fun isContract(address: String): Boolean { + return try { + val rpcApi = polygonRpcApi + + val rpcRequest = JsonRpcRequest( + method = "eth_getCode", + params = listOf(address, "latest") + ) + + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return false + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + return false + } + + val code = rpcResponse.result?.asString ?: "0x" + // 如果代码不是 "0x" 或 "0x0",则是合约 + code != "0x" && code != "0x0" + } catch (e: Exception) { + logger.warn("检查合约地址失败: ${e.message}") + false + } + } /** * 查询账户 USDC 余额 @@ -147,12 +251,6 @@ class BlockchainService( */ suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法查询 USDC 余额") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法查询 USDC 余额。请在配置文件中设置 polygon.rpc.url 环境变量")) - } - // 检查代理地址是否为空 if (proxyAddress.isBlank()) { logger.error("代理地址为空,无法查询余额") @@ -173,7 +271,7 @@ class BlockchainService( * 通过 RPC 查询 USDC 余额 */ private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 构建 ERC-20 balanceOf 函数调用 // function signature: balanceOf(address) -> bytes4(0x70a08231) @@ -208,7 +306,9 @@ class BlockchainService( throw Exception("RPC 错误: ${rpcResponse.error.message}") } - val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空") + // 使用 Gson 解析 result(JsonElement) + val hexBalance = rpcResponse.result?.asString + ?: throw Exception("RPC 响应格式错误: result 为空") // 将十六进制转换为 BigDecimal(USDC 有 6 位小数) val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16) @@ -264,13 +364,7 @@ class BlockchainService( */ suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法计算 tokenId") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法计算 tokenId")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 验证 outcomeIndex if (outcomeIndex < 0) { @@ -309,7 +403,9 @@ class BlockchainService( return Result.failure(Exception("调用 getCollectionId 失败: ${collectionIdResult.error}")) } - val collectionId = collectionIdResult.result ?: return Result.failure(Exception("getCollectionId 返回结果为空")) + // 使用 Gson 解析 result(JsonElement) + val collectionId = collectionIdResult.result?.asString + ?: return Result.failure(Exception("getCollectionId 返回结果为空")) // 2. 调用 getPositionId(collateralToken, collectionId) val getPositionIdSelector = EthereumUtils.getFunctionSelector("getPositionId(address,bytes32)") @@ -339,7 +435,9 @@ class BlockchainService( return Result.failure(Exception("调用 getPositionId 失败: ${positionIdResult.error}")) } - val tokenId = positionIdResult.result ?: return Result.failure(Exception("getPositionId 返回结果为空")) + // 使用 Gson 解析 result(JsonElement) + val tokenId = positionIdResult.result?.asString + ?: return Result.failure(Exception("getPositionId 返回结果为空")) val tokenIdBigInt = EthereumUtils.decodeUint256(tokenId) Result.success(tokenIdBigInt.toString()) @@ -449,7 +547,7 @@ class BlockchainService( * 获取代理钱包的 nonce(用于构建 Safe 交易) */ private suspend fun getProxyNonce(proxyAddress: String): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取 val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()") @@ -475,7 +573,9 @@ class BlockchainService( return Result.failure(Exception("获取 Proxy nonce 失败: ${rpcResponse.error.message}")) } - val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空")) + // 使用 Gson 解析 result(JsonElement) + val hexNonce = rpcResponse.result?.asString + ?: return Result.failure(Exception("Proxy nonce 结果为空")) val nonce = EthereumUtils.decodeUint256(hexNonce) return Result.success(nonce) } @@ -484,7 +584,7 @@ class BlockchainService( * 获取交易 nonce */ private suspend fun getTransactionCount(address: String): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi val rpcRequest = JsonRpcRequest( method = "eth_getTransactionCount", @@ -503,7 +603,9 @@ class BlockchainService( return Result.failure(Exception("获取 nonce 失败: ${rpcResponse.error.message}")) } - val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空")) + // 使用 Gson 解析 result(JsonElement) + val hexNonce = rpcResponse.result?.asString + ?: return Result.failure(Exception("nonce 结果为空")) val nonce = EthereumUtils.decodeUint256(hexNonce) return Result.success(nonce) } @@ -512,7 +614,7 @@ class BlockchainService( * 获取 gas price */ private suspend fun getGasPrice(): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi val rpcRequest = JsonRpcRequest( method = "eth_gasPrice", @@ -529,7 +631,9 @@ class BlockchainService( return Result.failure(Exception("获取 gas price 失败: ${rpcResponse.error.message}")) } - val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空")) + // 使用 Gson 解析 result(JsonElement) + val hexGasPrice = rpcResponse.result?.asString + ?: return Result.failure(Exception("gas price 结果为空")) val gasPrice = EthereumUtils.decodeUint256(hexGasPrice) return Result.success(gasPrice) } @@ -603,10 +707,79 @@ class BlockchainService( return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}")) } - val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空")) + // 使用 Gson 解析 result(JsonElement) + val txHash = rpcResponse.result?.asString + ?: return Result.failure(Exception("交易哈希为空")) return Result.success(txHash) } + /** + * 从链上查询市场条件(Condition)的结算结果 + * 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts + * + * @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串) + * @return Result> + * - payoutDenominator: 支付分母(通常为 1) + * - payouts: 每个 outcome 的支付金额数组(0 或 1) + * - 如果 payouts[outcomeIndex] == 1,表示该 outcome 赢了 + * - 如果 payouts[outcomeIndex] == 0,表示该 outcome 输了 + * - 如果 payouts 为空,表示市场尚未结算 + */ + suspend fun getCondition(conditionId: String): Result>> { + return try { + // 验证 conditionId 格式 + if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) { + return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串")) + } + + val rpcApi = polygonRpcApi + + // 构建 getCondition(bytes32) 函数调用 + // 函数签名: getCondition(bytes32) + val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)") + val encodedConditionId = EthereumUtils.encodeBytes32(conditionId) + val data = functionSelector + encodedConditionId + + // 构建 JSON-RPC 请求 + val rpcRequest = JsonRpcRequest( + method = "eth_call", + params = listOf( + mapOf( + "to" to conditionalTokensAddress, + "data" to data + ), + "latest" + ) + ) + + // 发送 RPC 请求 + val response = rpcApi.call(rpcRequest) + + if (!response.isSuccessful || response.body() == null) { + return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}")) + } + + val rpcResponse = response.body()!! + + // 检查错误 + if (rpcResponse.error != null) { + return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}")) + } + + // 使用 Gson 解析 result(JsonElement) + val hexResult = rpcResponse.result?.asString + ?: return Result.failure(Exception("RPC 响应格式错误: result 为空")) + + // 解析 ABI 编码的返回结果 + val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult) + + Result.success(Pair(payoutDenominator, payouts)) + } catch (e: Exception) { + logger.error("查询市场条件失败: conditionId=$conditionId, ${e.message}", e) + Result.failure(e) + } + } + /** * 查询交易详情(用于调试和分析) * @param txHash 交易哈希 @@ -614,11 +787,7 @@ class BlockchainService( */ suspend fun getTransactionDetails(txHash: String): Result { return try { - if (polygonRpcUrl.isBlank()) { - return Result.failure(IllegalStateException("未配置 Polygon RPC URL")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 查询交易 val txRequest = JsonRpcRequest( @@ -636,7 +805,9 @@ class BlockchainService( return Result.failure(Exception("查询交易失败: ${txRpcResponse.error.message}")) } - val txResult = txRpcResponse.result ?: return Result.failure(Exception("交易结果为空")) + // 使用 Gson 解析 result(JsonElement) + val txResult = txRpcResponse.result?.toString() + ?: return Result.failure(Exception("交易结果为空")) // 查询交易回执(包含内部调用和事件日志) val receiptRequest = JsonRpcRequest( @@ -653,7 +824,7 @@ class BlockchainService( val receiptResult = if (receiptRpcResponse.error != null) { "交易回执查询失败: ${receiptRpcResponse.error.message}" } else { - receiptRpcResponse.result ?: "交易回执为空(可能还在打包中)" + receiptRpcResponse.result?.toString() ?: "交易回执为空(可能还在打包中)" } Result.success("交易信息:\n$txResult\n\n交易回执:\n$receiptResult") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt new file mode 100644 index 0000000..9c94641 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketPriceService.kt @@ -0,0 +1,211 @@ +package com.wrbug.polymarketbot.service.common + +import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.util.CryptoUtils +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.BigInteger + +/** + * 市场价格服务 + * 统一封装从不同数据源获取市场价格的逻辑 + * 数据源包括: + * 1. 链上 RPC 查询(市场结算结果) + * 2. CLOB API(订单簿价格) + */ +@Service +class MarketPriceService( + private val blockchainService: BlockchainService, + private val retrofitFactory: RetrofitFactory, + private val accountRepository: AccountRepository, + private val cryptoUtils: CryptoUtils +) { + + private val logger = LoggerFactory.getLogger(MarketPriceService::class.java) + + /** + * 获取当前市场最新价 + * 优先级: + * 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0) + * 2. CLOB API 查询订单簿价格(最准确,使用 bestBid) + * + * 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算 + * + * @param marketId 市场ID + * @param outcomeIndex 结果索引 + * @return 市场价格(已截位到 4 位小数) + * @throws IllegalStateException 如果所有数据源都失败 + */ + suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal { + // 1. 优先从链上查询市场结算结果 + val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex) + if (chainPrice != null) { + // 截位到 4 位小数(向下截断,不四舍五入) + return chainPrice.setScale(4, java.math.RoundingMode.DOWN) + } + + // 2. 从 CLOB API 查询订单簿价格(最准确) + val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex) + if (orderbookPrice != null) { + // 截位到 4 位小数(向下截断,不四舍五入) + return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN) + } + + // 如果所有数据源都失败,抛出异常 + val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + /** + * 从链上查询市场结算结果获取价格 + * 如果市场已结算: + * - payout > 0(赢了)→ 返回 1.0 + * - payout == 0(输了)→ 返回 0.0 + * 如果市场未结算或查询失败,返回 null + */ + private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? { + return try { + val chainResult = blockchainService.getCondition(marketId) + chainResult.fold( + onSuccess = { (_, payouts) -> + // 如果 payouts 不为空,说明市场已结算 + if (payouts.isNotEmpty() && outcomeIndex < payouts.size) { + val payout = payouts[outcomeIndex] + when { + payout > BigInteger.ZERO -> { + logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout") + return BigDecimal.ONE + } + payout == BigInteger.ZERO -> { + logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout") + return BigDecimal.ZERO + } + else -> { + logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout") + null + } + } + } else { + logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}") + null + } + }, + onFailure = { e -> + logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}") + null + } + ) + } catch (e: Exception) { + logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}") + null + } + } + + + /** + * 从 CLOB API 查询订单簿价格 + * 获取订单簿的 bestBid 和 bestAsk,计算 midpoint = (bestBid + bestAsk) / 2 + * 订单簿数据最准确,反映当前市场真实价格 + * 如果查询失败,返回 null + */ + private suspend fun getPriceFromClobOrderbook(marketId: String, outcomeIndex: Int): BigDecimal? { + return try { + // 获取 tokenId(用于查询特定 outcome 的订单簿) + val tokenIdResult = blockchainService.getTokenId(marketId, outcomeIndex) + if (!tokenIdResult.isSuccess) { + return null + } + + val tokenId = tokenIdResult.getOrNull() ?: return null + + // 尝试使用带鉴权的 CLOB API,如果没有则使用不带鉴权的 API + val clobApi = try { + getAuthenticatedClobApi() ?: retrofitFactory.createClobApiWithoutAuth() + } catch (e: Exception) { + logger.debug("获取带鉴权的 CLOB API 失败,使用不带鉴权的 API: ${e.message}") + retrofitFactory.createClobApiWithoutAuth() + } + + val orderbookResponse = clobApi.getOrderbook(tokenId = tokenId, market = null) + + if (!orderbookResponse.isSuccessful || orderbookResponse.body() == null) { + return null + } + + val orderbook = orderbookResponse.body()!! + + // 获取 bestBid(最高买入价):从 bids 中找到价格最大的 + // bids 表示买入订单列表,价格越高表示愿意出的价格越高 + val bestBid = orderbook.bids + .mapNotNull { it.price.toSafeBigDecimal() } + .maxOrNull() + + // 获取 bestAsk(最低卖出价):从 asks 中找到价格最小的 + // asks 表示卖出订单列表,价格越低表示愿意卖的价格越低 + val bestAsk = orderbook.asks + .mapNotNull { it.price.toSafeBigDecimal() } + .minOrNull() + + // 由于主要用于卖出场景,优先使用 bestBid(最高买入价,卖给愿意买入的人) + // 如果没有 bestBid,则使用 midpoint 或 bestAsk + if (bestBid != null) { + logger.debug("从订单簿获取价格(bestBid): marketId=$marketId, outcomeIndex=$outcomeIndex, bestBid=$bestBid, bestAsk=$bestAsk") + return bestBid + } else if (bestAsk != null && bestAsk > BigDecimal.ZERO) { + // 如果没有 bestBid,使用 bestAsk 作为备选 + logger.debug("从订单簿获取价格(bestAsk): marketId=$marketId, outcomeIndex=$outcomeIndex, bestAsk=$bestAsk") + return bestAsk + } + + null + } catch (e: Exception) { + logger.debug("CLOB API 查询订单簿失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}") + null + } + } + + /** + * 获取带鉴权的 CLOB API 客户端 + * 使用第一个有 API 凭证的账户 + * 如果都没有,返回 null + */ + private fun getAuthenticatedClobApi(): PolymarketClobApi? { + return try { + // 使用第一个有 API 凭证的账户 + val account = accountRepository.findAllByOrderByCreatedAtAsc() + .firstOrNull { it.apiKey != null && it.apiSecret != null && it.apiPassphrase != null } + + if (account == null || account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + return null + } + + // 解密 API 凭证 + val apiKey = account.apiKey + val apiSecret = try { + cryptoUtils.decrypt(account.apiSecret) + } catch (e: Exception) { + logger.debug("解密 API Secret 失败: ${e.message}") + return null + } + val apiPassphrase = try { + cryptoUtils.decrypt(account.apiPassphrase) + } catch (e: Exception) { + logger.debug("解密 API Passphrase 失败: ${e.message}") + return null + } + + // 创建带鉴权的 CLOB API 客户端 + retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress) + } catch (e: Exception) { + logger.debug("获取带鉴权的 CLOB API 失败: ${e.message}") + null + } + } + +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt index 339634c..37ff3ee 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt @@ -1,5 +1,6 @@ package com.wrbug.polymarketbot.service.common +import com.google.gson.Gson import com.wrbug.polymarketbot.api.ApiKeyResponse import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor @@ -19,7 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory @Service class PolymarketApiKeyService( @Value("\${polymarket.clob.base-url}") - private val clobBaseUrl: String + private val clobBaseUrl: String, + private val gson: Gson ) { private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java) @@ -52,34 +54,36 @@ class PolymarketApiKeyService( try { // 先尝试获取现有的 API Key(derive) val deriveResult = deriveApiKey(privateKey, walletAddress, chainId) + val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}" if (deriveResult.isSuccess) { val creds = deriveResult.getOrNull() if (creds != null && isApiCreds(creds)) { - logger.info("成功获取现有 API Key: ${walletAddress}") + logger.debug("成功获取现有 API Key: $maskedAddress") return@runBlocking Result.success(creds) } } - + // 如果获取失败或返回无效,尝试创建新的 - logger.info("获取现有 API Key 失败,尝试创建新的: ${walletAddress}") + logger.debug("获取现有 API Key 失败,尝试创建新的: $maskedAddress") val createResult = createApiKey(privateKey, walletAddress, chainId) if (createResult.isSuccess) { val creds = createResult.getOrNull() if (creds != null && isApiCreds(creds)) { - logger.info("成功创建新 API Key: ${walletAddress}") + logger.debug("成功创建新 API Key: $maskedAddress") return@runBlocking Result.success(creds) } } - + // 两个都失败 val error = createResult.exceptionOrNull() ?: deriveResult.exceptionOrNull() val errorMsg = error?.message ?: "未知错误" - logger.error("获取和创建 API Key 都失败: ${walletAddress}", error) + logger.error("获取和创建 API Key 都失败: $maskedAddress", error) Result.failure( IllegalStateException("无法获取或创建 API Key: $errorMsg") ) } catch (e: Exception) { - logger.error("创建或获取 API Key 异常: ${walletAddress}", e) + val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}" + logger.error("创建或获取 API Key 异常: $maskedAddress", e) Result.failure(e) } } @@ -222,7 +226,7 @@ class PolymarketApiKeyService( return Retrofit.Builder() .baseUrl(clobBaseUrl) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() .create(PolymarketClobApi::class.java) } @@ -236,7 +240,7 @@ class PolymarketApiKeyService( return Retrofit.Builder() .baseUrl(clobBaseUrl) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() .create(PolymarketClobApi::class.java) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt index 6cb5b9c..30f1551 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt @@ -300,13 +300,23 @@ class PolymarketClobService( ) val response = authenticatedClobApi.getOrder(orderId) - if (response.isSuccessful && response.body() != null) { - Result.success(response.body()!!) + if (response.isSuccessful) { + val body = response.body() + if (body != null) { + Result.success(body) + } else { + // 响应体为空,可能是订单不存在或已过期 + logger.warn("获取订单详情失败: 响应体为空, orderId=$orderId, code=${response.code()}") + Result.failure(Exception("订单不存在或已过期: orderId=$orderId")) + } } else { - Result.failure(Exception("获取订单详情失败: ${response.code()} ${response.message()}")) + // HTTP 状态码不是 2xx + val errorBody = response.errorBody()?.string()?.take(200) ?: "无错误详情" + logger.warn("获取订单详情失败: HTTP ${response.code()}, orderId=$orderId, errorBody=$errorBody") + Result.failure(Exception("获取订单详情失败: HTTP ${response.code()} ${response.message()}")) } } catch (e: Exception) { - logger.error("获取订单详情异常: ${e.message}", e) + logger.error("获取订单详情异常: orderId=$orderId, ${e.message}", e) Result.failure(e) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt index ca0bf41..4687857 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt @@ -3,50 +3,133 @@ package com.wrbug.polymarketbot.service.common import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference /** - * 频率限制服务(使用内存缓存,全局限制) + * 频率限制服务(使用内存缓存) */ @Service class RateLimitService { - + private val logger = LoggerFactory.getLogger(RateLimitService::class.java) - + + // 重置密码限速配置 @Value("\${rate-limit.reset-password.max-attempts:3}") - private var maxAttempts: Int = 3 - + private var resetPasswordMaxAttempts: Int = 3 + @Value("\${rate-limit.reset-password.window-seconds:60}") - private var windowSeconds: Long = 60 - + private var resetPasswordWindowSeconds: Long = 60 + + // 登录限速配置 + @Value("\${rate-limit.login.max-attempts:5}") + private var loginMaxAttempts: Int = 5 + + @Value("\${rate-limit.login.window-seconds:300}") + private var loginWindowSeconds: Long = 300 // 5分钟 + + @Value("\${rate-limit.login.lockout-seconds:900}") + private var loginLockoutSeconds: Long = 900 // 15分钟 + // 全局尝试记录列表(时间戳),所有请求共享 private val resetPasswordAttempts = AtomicReference>(mutableListOf()) - + + // 登录失败尝试记录(IP -> 时间戳列表) + private val loginFailedAttempts = ConcurrentHashMap>() + + // 登录锁定记录(IP -> 锁定结束时间) + private val loginLockouts = ConcurrentHashMap() + /** * 检查重置密码频率限制(全局限制,不按IP) * @return Result,如果超过限制则返回失败 */ fun checkResetPasswordRateLimit(): Result { val now = System.currentTimeMillis() - val windowStart = now - (windowSeconds * 1000) - + val windowStart = now - (resetPasswordWindowSeconds * 1000) + // 获取当前尝试记录列表 val attempts = resetPasswordAttempts.get() - + // 清理过期记录(超过时间窗口的记录) val validAttempts = attempts.filter { it >= windowStart }.toMutableList() - + // 检查是否超过限制 - if (validAttempts.size >= maxAttempts) { - logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts") - return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试")) + if (validAttempts.size >= resetPasswordMaxAttempts) { + logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts") + return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试")) } - + // 记录本次尝试 validAttempts.add(now) resetPasswordAttempts.set(validAttempts) - + return Result.success(Unit) } + + /** + * 检查登录频率限制(按IP限制) + * @param ipAddress 客户端IP地址 + * @return Result,如果被锁定或超过限制则返回失败 + */ + fun checkLoginRateLimit(ipAddress: String): Result { + val now = System.currentTimeMillis() + + // 检查是否被锁定 + val lockoutEndTime = loginLockouts[ipAddress] + if (lockoutEndTime != null) { + if (now < lockoutEndTime) { + val remainingSeconds = (lockoutEndTime - now) / 1000 + logger.warn("登录锁定中: ip=$ipAddress, remainingSeconds=$remainingSeconds") + return Result.failure(IllegalStateException("账户已被锁定,请${remainingSeconds}秒后再试")) + } else { + // 锁定已过期,清除锁定记录 + loginLockouts.remove(ipAddress) + loginFailedAttempts.remove(ipAddress) + } + } + + return Result.success(Unit) + } + + /** + * 记录登录失败尝试 + * @param ipAddress 客户端IP地址 + * @return 如果触发锁定返回锁定信息,否则返回 null + */ + fun recordLoginFailure(ipAddress: String): String? { + val now = System.currentTimeMillis() + val windowStart = now - (loginWindowSeconds * 1000) + + // 获取或创建该IP的尝试记录 + val attempts = loginFailedAttempts.computeIfAbsent(ipAddress) { mutableListOf() } + + // 清理过期记录并添加新记录 + synchronized(attempts) { + attempts.removeIf { it < windowStart } + attempts.add(now) + + // 检查是否需要锁定 + if (attempts.size >= loginMaxAttempts) { + val lockoutEndTime = now + (loginLockoutSeconds * 1000) + loginLockouts[ipAddress] = lockoutEndTime + logger.warn("登录锁定触发: ip=$ipAddress, attempts=${attempts.size}, lockoutSeconds=$loginLockoutSeconds") + return "登录失败次数过多,账户已被锁定${loginLockoutSeconds / 60}分钟" + } + } + + val remainingAttempts = loginMaxAttempts - attempts.size + logger.warn("登录失败: ip=$ipAddress, attempts=${attempts.size}/$loginMaxAttempts, remainingAttempts=$remainingAttempts") + return null + } + + /** + * 登录成功时清除失败记录 + * @param ipAddress 客户端IP地址 + */ + fun clearLoginFailures(ipAddress: String) { + loginFailedAttempts.remove(ipAddress) + loginLockouts.remove(ipAddress) + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt index e3eb97d..3a73ff0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt @@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.util.multi import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import com.wrbug.polymarketbot.service.common.PolymarketClobService +import com.wrbug.polymarketbot.service.accounts.AccountService import org.springframework.stereotype.Service import java.math.BigDecimal @@ -16,7 +17,8 @@ import java.math.BigDecimal */ @Service class CopyTradingFilterService( - private val clobService: PolymarketClobService + private val clobService: PolymarketClobService, + private val accountService: AccountService ) { private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java) @@ -26,12 +28,16 @@ class CopyTradingFilterService( * @param copyTrading 跟单配置 * @param tokenId token ID(用于获取订单簿) * @param tradePrice Leader 交易价格,用于价格区间检查 + * @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查 + * @param marketId 市场ID,用于仓位检查(按市场过滤仓位) * @return 过滤结果 */ suspend fun checkFilters( copyTrading: CopyTrading, tokenId: String, - tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查 + tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查 + copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查 + marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位) ): FilterResult { // 1. 价格区间检查(如果配置了价格区间) if (tradePrice != null) { @@ -76,6 +82,14 @@ class CopyTradingFilterService( } } + // 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID) + if (copyOrderAmount != null && marketId != null) { + val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId) + if (!positionCheck.isPassed) { + return positionCheck + } + } + return FilterResult.passed(orderbook) } @@ -184,5 +198,78 @@ class CopyTradingFilterService( return FilterResult.passed() } + + /** + * 检查仓位限制(按市场检查) + * @param copyTrading 跟单配置 + * @param copyOrderAmount 跟单金额(USDC) + * @param marketId 市场ID,用于过滤该市场的仓位 + * @return 过滤结果 + */ + private suspend fun checkPositionLimits( + copyTrading: CopyTrading, + copyOrderAmount: BigDecimal, + marketId: String + ): FilterResult { + // 如果未配置仓位限制,直接通过 + if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) { + return FilterResult.passed() + } + + try { + // 获取账户的所有仓位信息 + val positionsResult = accountService.getAllPositions() + if (positionsResult.isFailure) { + logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}") + // 如果获取仓位失败,为了安全起见,不通过检查 + return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查") + } + + val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空") + + // 过滤出当前账户且该市场的仓位 + val marketPositions = positions.currentPositions.filter { + it.accountId == copyTrading.accountId && it.marketId == marketId + } + + // 检查最大仓位金额(如果配置了) + if (copyTrading.maxPositionValue != null) { + // 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue) + val currentPositionValue = marketPositions.sumOf { position -> + position.currentValue.toSafeBigDecimal() + } + + // 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额 + val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount) + + if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) { + return FilterResult.maxPositionValueFailed( + "超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC" + ) + } + } + + // 检查最大仓位数量(如果配置了) + if (copyTrading.maxPositionCount != null) { + // 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位) + val currentPositionCount = marketPositions.size + + // 检查:该市场的当前仓位数量 <= 最大仓位数量 + // 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位 + // 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制 + if (currentPositionCount >= copyTrading.maxPositionCount) { + return FilterResult.maxPositionCountFailed( + "超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}" + ) + } + } + + return FilterResult.passed() + } catch (e: Exception) { + logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e) + // 如果检查异常,为了安全起见,不通过检查 + return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}") + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt index 2ed420f..af9f4a1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt @@ -86,7 +86,9 @@ class CopyTradingService( minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread, minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice, - maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice + maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice, + maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(), + maxPositionCount = request.maxPositionCount ) } else { // 手动输入(所有字段必须提供) @@ -112,7 +114,9 @@ class CopyTradingService( minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(), maxSpread = request.maxSpread?.toSafeBigDecimal(), minPrice = request.minPrice?.toSafeBigDecimal(), - maxPrice = request.maxPrice?.toSafeBigDecimal() + maxPrice = request.maxPrice?.toSafeBigDecimal(), + maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(), + maxPositionCount = request.maxPositionCount ) } @@ -139,19 +143,22 @@ class CopyTradingService( maxSpread = config.maxSpread, minPrice = config.minPrice, maxPrice = config.maxPrice, + maxPositionValue = config.maxPositionValue, + maxPositionCount = config.maxPositionCount, configName = configName, pushFailedOrders = request.pushFailedOrders ?: false ) val saved = copyTradingRepository.save(copyTrading) - // 如果跟单已启用,重新启动监听(确保状态完全同步) + // 如果跟单已启用,更新 Leader 监听和账户监听(增量更新,不重启所有监听) if (saved.enabled) { kotlinx.coroutines.runBlocking { try { - monitorService.restartMonitoring() + monitorService.updateLeaderMonitoring(saved.leaderId) + monitorService.updateAccountMonitoring(saved.accountId) } catch (e: Exception) { - logger.error("重新启动跟单监听失败", e) + logger.error("更新监听失败", e) } } } @@ -204,6 +211,8 @@ class CopyTradingService( maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread, minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice, + maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue, + maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount, configName = configName, pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders, updatedAt = System.currentTimeMillis() @@ -211,12 +220,13 @@ class CopyTradingService( val saved = copyTradingRepository.save(updated) - // 重新启动监听(确保状态完全同步) + // 更新 Leader 监听和账户监听(增量更新,根据 enabled 状态决定添加或移除) kotlinx.coroutines.runBlocking { try { - monitorService.restartMonitoring() + monitorService.updateLeaderMonitoring(saved.leaderId) + monitorService.updateAccountMonitoring(saved.accountId) } catch (e: Exception) { - logger.error("重新启动跟单监听失败", e) + logger.error("更新监听失败", e) } } @@ -314,14 +324,17 @@ class CopyTradingService( val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null) ?: return Result.failure(IllegalArgumentException("跟单配置不存在")) + val leaderId = copyTrading.leaderId + val accountId = copyTrading.accountId copyTradingRepository.delete(copyTrading) - // 重新启动监听(确保状态完全同步) + // 更新 Leader 监听和账户监听(检查是否还有其他启用的跟单配置) kotlinx.coroutines.runBlocking { try { - monitorService.restartMonitoring() + monitorService.removeLeaderMonitoring(leaderId) + monitorService.updateAccountMonitoring(accountId) } catch (e: Exception) { - logger.error("重新启动跟单监听失败", e) + logger.error("更新监听失败", e) } } @@ -409,6 +422,8 @@ class CopyTradingService( maxSpread = copyTrading.maxSpread?.toPlainString(), minPrice = copyTrading.minPrice?.toPlainString(), maxPrice = copyTrading.maxPrice?.toPlainString(), + maxPositionValue = copyTrading.maxPositionValue?.toPlainString(), + maxPositionCount = copyTrading.maxPositionCount, configName = copyTrading.configName, pushFailedOrders = copyTrading.pushFailedOrders, createdAt = copyTrading.createdAt, @@ -437,6 +452,8 @@ class CopyTradingService( val minOrderDepth: BigDecimal?, val maxSpread: BigDecimal?, val minPrice: BigDecimal?, - val maxPrice: BigDecimal? + val maxPrice: BigDecimal?, + val maxPositionValue: BigDecimal?, + val maxPositionCount: Int? ) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt index c4041e8..6d210a8 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt @@ -17,7 +17,11 @@ enum class FilterStatus { /** 失败:价差过大 */ FAILED_SPREAD, /** 失败:订单深度不足 */ - FAILED_ORDER_DEPTH + FAILED_ORDER_DEPTH, + /** 失败:超过最大仓位金额 */ + FAILED_MAX_POSITION_VALUE, + /** 失败:超过最大仓位数量 */ + FAILED_MAX_POSITION_COUNT } /** @@ -73,6 +77,18 @@ data class FilterResult( reason = reason, orderbook = orderbook ) + + /** 超过最大仓位金额 */ + fun maxPositionValueFailed(reason: String) = FilterResult( + status = FilterStatus.FAILED_MAX_POSITION_VALUE, + reason = reason + ) + + /** 超过最大仓位数量 */ + fun maxPositionCountFailed(reason: String) = FilterResult( + status = FilterStatus.FAILED_MAX_POSITION_COUNT, + reason = reason + ) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/AccountOnChainMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/AccountOnChainMonitorService.kt new file mode 100644 index 0000000..6209925 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/AccountOnChainMonitorService.kt @@ -0,0 +1,341 @@ +package com.wrbug.polymarketbot.service.copytrading.monitor + +import com.wrbug.polymarketbot.api.* +import com.wrbug.polymarketbot.entity.Account +import com.wrbug.polymarketbot.entity.CopyOrderTracking +import com.wrbug.polymarketbot.entity.SellMatchDetail +import com.wrbug.polymarketbot.entity.SellMatchRecord +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository +import com.wrbug.polymarketbot.repository.CopyTradingRepository +import com.wrbug.polymarketbot.repository.SellMatchDetailRepository +import com.wrbug.polymarketbot.repository.SellMatchRecordRepository +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.multi +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap + +/** + * 跟单账户链上 WebSocket 监听服务 + * 通过统一服务订阅跟单账户的卖出和赎回事件 + * 用于更新订单状态,不再依赖轮询 + */ +@Service +class AccountOnChainMonitorService( + private val unifiedOnChainWsService: UnifiedOnChainWsService, + private val retrofitFactory: RetrofitFactory, + private val accountRepository: AccountRepository, + private val copyTradingRepository: CopyTradingRepository, + private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val sellMatchRecordRepository: SellMatchRecordRepository, + private val sellMatchDetailRepository: SellMatchDetailRepository +) { + + private val logger = LoggerFactory.getLogger(AccountOnChainMonitorService::class.java) + + // 存储需要监听的账户:accountId -> Account + private val monitoredAccounts = ConcurrentHashMap() + + /** + * 启动链上 WebSocket 监听 + * 通过统一服务订阅所有跟单账户 + */ + fun start(accounts: List) { + // 如果没有账户,取消所有订阅 + if (accounts.isEmpty()) { + logger.info("没有需要监听的跟单账户,取消所有订阅") + stop() + return + } + + // 更新账户列表 + monitoredAccounts.clear() + accounts.forEach { account -> + addAccount(account) + } + } + + /** + * 添加账户监听 + * 通过统一服务订阅该账户的地址 + */ + fun addAccount(account: Account) { + if (account.id == null) { + logger.warn("账户 ID 为空,跳过: ${account.proxyAddress}") + return + } + + val accountId = account.id!! + + // 如果已经在监听列表中,不重复添加 + if (monitoredAccounts.containsKey(accountId)) { + return + } + + monitoredAccounts[accountId] = account + + // 通过统一服务订阅 + val subscriptionId = "ACCOUNT_$accountId" + unifiedOnChainWsService.subscribe( + subscriptionId = subscriptionId, + address = account.proxyAddress, + entityType = "ACCOUNT", + entityId = accountId, + callback = { txHash, httpClient, rpcApi -> + handleAccountTransaction(accountId, txHash, httpClient, rpcApi) + } + ) + + logger.info("已添加跟单账户进行链上监听: accountId=${accountId}, address=${account.proxyAddress}") + } + + /** + * 处理账户的交易 + */ + private suspend fun handleAccountTransaction(accountId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) { + val account = monitoredAccounts[accountId] ?: return + + try { + // 获取交易 receipt + val receiptRequest = JsonRpcRequest( + method = "eth_getTransactionReceipt", + params = listOf(txHash) + ) + + val receiptResponse = rpcApi.call(receiptRequest) + if (!receiptResponse.isSuccessful || receiptResponse.body() == null) { + return + } + + val receiptRpcResponse = receiptResponse.body()!! + if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) { + return + } + + // 使用 Gson 解析 receipt JSON + val receiptJson = receiptRpcResponse.result.asJsonObject + + // 获取区块号和时间戳 + val blockNumber = receiptJson.get("blockNumber")?.asString + val blockTimestamp = if (blockNumber != null) { + OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi) + } else { + null + } + + // 解析 receipt 中的 Transfer 日志 + val logs = receiptJson.getAsJsonArray("logs") ?: return + val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs) + + // 解析交易信息 + val trade = OnChainWsUtils.parseTradeFromTransfers( + txHash = txHash, + timestamp = blockTimestamp, + walletAddress = account.proxyAddress, + erc20Transfers = erc20Transfers, + erc1155Transfers = erc1155Transfers, + retrofitFactory = retrofitFactory + ) + + if (trade != null && trade.side == "SELL") { + // 检测到卖出或赎回事件,更新订单状态 + handleAccountSellOrRedeem(account, trade) + } + } catch (e: Exception) { + logger.error("处理账户交易失败: accountId=$accountId, txHash=$txHash, ${e.message}", e) + } + } + + /** + * 处理账户的卖出或赎回事件 + * 更新对应的订单状态 + */ + private suspend fun handleAccountSellOrRedeem(account: Account, trade: TradeResponse) { + try { + // 获取该账户的所有启用的跟单配置 + val copyTradings = copyTradingRepository.findByAccountId(account.id!!) + .filter { it.enabled } + + if (copyTradings.isEmpty()) { + return + } + + // 使用 trade 中已有的市场信息 + val marketId = trade.market // conditionId + val outcomeIndex = trade.outcomeIndex ?: 0 + + // 计算卖出价格 + val sellPrice = trade.price.toSafeBigDecimal() + + // 为每个跟单配置更新订单状态 + for (copyTrading in copyTradings) { + // 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0) + val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!) + .filter { + it.remainingQuantity > BigDecimal.ZERO && + it.marketId == marketId && + it.outcomeIndex == outcomeIndex + } + .sortedBy { it.createdAt } // 按创建时间排序(FIFO) + + if (unmatchedOrders.isEmpty()) { + continue + } + + // 卖出数量就是交易的 size + val soldQuantity = trade.size.toSafeBigDecimal() + + // 更新订单状态为已卖出 + updateOrdersAsSoldByFIFO( + unmatchedOrders, + soldQuantity, + sellPrice, + copyTrading.id!!, + marketId, + outcomeIndex + ) + + logger.info("跟单账户卖出/赎回事件处理完成: accountId=${account.id}, copyTradingId=${copyTrading.id}, txHash=${trade.id}, soldQuantity=$soldQuantity, sellPrice=$sellPrice") + } + } catch (e: Exception) { + logger.error("处理账户卖出/赎回事件失败: accountId=${account.id}, txHash=${trade.id}, error=${e.message}", e) + } + } + + /** + * 按 FIFO 顺序更新订单为已卖出 + */ + private suspend fun updateOrdersAsSoldByFIFO( + orders: List, + soldQuantity: BigDecimal, + sellPrice: BigDecimal, + copyTradingId: Long, + marketId: String, + outcomeIndex: Int + ) { + var remainingSoldQuantity = soldQuantity + val matchDetails = mutableListOf() + var totalMatchedQuantity = BigDecimal.ZERO + var totalRealizedPnl = BigDecimal.ZERO + + for (order in orders) { + if (remainingSoldQuantity <= BigDecimal.ZERO) { + break + } + + val currentOrderRemaining = order.remainingQuantity.toSafeBigDecimal() + val matchedQty = minOf(currentOrderRemaining, remainingSoldQuantity) + + if (matchedQty <= BigDecimal.ZERO) { + continue + } + + // 计算盈亏 + val buyPrice = order.price.toSafeBigDecimal() + val realizedPnl = sellPrice.subtract(buyPrice).multi(matchedQty) + + // 创建匹配明细 + val detail = SellMatchDetail( + matchRecordId = 0, // 稍后设置 + trackingId = order.id!!, + buyOrderId = order.buyOrderId, + matchedQuantity = matchedQty, + buyPrice = buyPrice, + sellPrice = sellPrice, + realizedPnl = realizedPnl + ) + matchDetails.add(detail) + + totalMatchedQuantity = totalMatchedQuantity.add(matchedQty) + totalRealizedPnl = totalRealizedPnl.add(realizedPnl) + + // 更新订单状态 + order.matchedQuantity = order.matchedQuantity.add(matchedQty) + order.remainingQuantity = currentOrderRemaining.subtract(matchedQty) + order.status = if (order.remainingQuantity <= BigDecimal.ZERO) "fully_matched" else "partially_matched" + order.updatedAt = System.currentTimeMillis() + copyOrderTrackingRepository.save(order) + + remainingSoldQuantity = remainingSoldQuantity.subtract(matchedQty) + } + + // 如果有匹配的订单,创建卖出记录 + if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) { + val timestamp = System.currentTimeMillis() + val sellOrderId = "AUTO_WS_${timestamp}_${copyTradingId}" // 区分 WS 自动卖出 + val leaderSellTradeId = "AUTO_WS_${timestamp}" + + val matchRecord = SellMatchRecord( + copyTradingId = copyTradingId, + sellOrderId = sellOrderId, + leaderSellTradeId = leaderSellTradeId, + marketId = marketId, + side = outcomeIndex.toString(), + outcomeIndex = outcomeIndex, + totalMatchedQuantity = totalMatchedQuantity, + sellPrice = sellPrice, + totalRealizedPnl = totalRealizedPnl, + priceUpdated = true // WS 实时获取,直接标记为已更新 + ) + + val savedRecord = sellMatchRecordRepository.save(matchRecord) + + // 保存匹配明细 + for (detail in matchDetails) { + val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) + sellMatchDetailRepository.save(savedDetail) + } + + logger.info("创建跟单账户链上自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl") + } + } + + /** + * 移除账户监听 + * 取消该账户的订阅 + */ + fun removeAccount(accountId: Long) { + monitoredAccounts.remove(accountId) + + // 通过统一服务取消订阅 + val subscriptionId = "ACCOUNT_$accountId" + unifiedOnChainWsService.unsubscribe(subscriptionId) + + logger.info("已移除跟单账户的链上监听: accountId=$accountId") + } + + /** + * 更新账户监听状态 + */ + fun updateAccountMonitoring(accountId: Long) { + val account = accountRepository.findById(accountId).orElse(null) + if (account != null && account.isEnabled) { + addAccount(account) + } else { + removeAccount(accountId) + } + } + + /** + * 停止监听 + */ + fun stop() { + // 取消所有账户的订阅 + val accountIds = monitoredAccounts.keys.toList() + for (accountId in accountIds) { + removeAccount(accountId) + } + monitoredAccounts.clear() + } + + @PreDestroy + fun destroy() { + stop() + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingMonitorService.kt index b622e16..d4b5c89 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingMonitorService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingMonitorService.kt @@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.service.copytrading.monitor import com.wrbug.polymarketbot.entity.CopyTrading import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.CopyTradingRepository import com.wrbug.polymarketbot.repository.LeaderRepository import jakarta.annotation.PostConstruct @@ -12,14 +13,18 @@ import org.springframework.stereotype.Service /** * 跟单监听服务(主服务) - * 管理所有Leader的交易监听(使用轮询方式) - * 注意:WebSocket 需要认证才能订阅其他用户的交易,因此只使用轮询方式 + * 管理所有Leader的交易监听 + * 同时运行链上 WebSocket 监听和轮询监听(并行处理) + * 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket) */ @Service class CopyTradingMonitorService( private val copyTradingRepository: CopyTradingRepository, private val leaderRepository: LeaderRepository, - private val pollingService: CopyTradingPollingService + private val accountRepository: AccountRepository, + private val pollingService: CopyTradingPollingService, + private val onChainWsService: OnChainWsService, + private val accountOnChainMonitorService: AccountOnChainMonitorService ) { private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java) @@ -46,12 +51,16 @@ class CopyTradingMonitorService( @PreDestroy fun destroy() { scope.cancel() - // 只使用轮询,不使用WebSocket + // 停止轮询和链上 WS 监听 pollingService.stop() + onChainWsService.stop() + accountOnChainMonitorService.stop() } /** * 启动监听 + * 同时启动链上 WebSocket 监听和轮询监听(并行运行) + * 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件) */ suspend fun startMonitoring() { // 1. 获取所有启用的跟单关系 @@ -67,14 +76,26 @@ class CopyTradingMonitorService( leaderRepository.findById(leaderId).orElse(null) } + // 3. 获取所有需要监听的跟单账户(去重) + val accountIds = enabledCopyTradings.map { it.accountId }.distinct() + val accounts = accountIds.mapNotNull { accountId -> + accountRepository.findById(accountId).orElse(null) + } - // 3. 启动轮询监听(使用 /activity 接口,不需要认证) - // 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询 + // 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行) + // 链上 WS 监听 Leader 的交易(实时,秒级延迟) + onChainWsService.start(leaders) + + // 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份) pollingService.start(leaders) + + // 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件) + accountOnChainMonitorService.start(accounts) } /** * 添加Leader监听(当创建新的跟单关系时调用) + * 如果 Leader 已经在监听列表中,不重复添加 */ suspend fun addLeaderMonitoring(leaderId: Long) { val leader = leaderRepository.findById(leaderId).orElse(null) @@ -85,28 +106,82 @@ class CopyTradingMonitorService( return } - // 只使用轮询,不使用WebSocket(需要认证) + // 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加) + onChainWsService.addLeader(leader) pollingService.addLeader(leader) } /** - * 移除Leader监听(当删除跟单关系时调用) + * 移除Leader监听(当删除跟单关系或禁用时调用) + * 检查该 Leader 是否还有其他启用的跟单配置 */ suspend fun removeLeaderMonitoring(leaderId: Long) { val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) + // 如果还有启用的跟单配置,不移除监听 if (copyTradings.isNotEmpty()) { return } - // 只使用轮询,不使用WebSocket + // 没有启用的跟单配置了,移除监听 + onChainWsService.removeLeader(leaderId) pollingService.removeLeader(leaderId) } + /** + * 更新Leader监听(当跟单配置状态改变时调用) + * 根据当前状态决定添加或移除监听 + */ + suspend fun updateLeaderMonitoring(leaderId: Long) { + val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) + val leader = leaderRepository.findById(leaderId).orElse(null) + ?: return + + if (copyTradings.isNotEmpty()) { + // 有启用的跟单配置,确保在监听列表中 + onChainWsService.addLeader(leader) + pollingService.addLeader(leader) + + // 更新账户监听(添加该配置关联的账户) + val accountIds = copyTradings.map { it.accountId }.distinct() + accountIds.forEach { accountId -> + val account = accountRepository.findById(accountId).orElse(null) + if (account != null) { + accountOnChainMonitorService.addAccount(account) + } + } + } else { + // 没有启用的跟单配置,移除监听 + onChainWsService.removeLeader(leaderId) + pollingService.removeLeader(leaderId) + } + } + + /** + * 更新账户监听(当跟单配置状态改变时调用) + * 根据当前状态决定添加或移除账户监听 + */ + suspend fun updateAccountMonitoring(accountId: Long) { + val copyTradings = copyTradingRepository.findByAccountId(accountId) + .filter { it.enabled } + val account = accountRepository.findById(accountId).orElse(null) + ?: return + + if (copyTradings.isNotEmpty()) { + // 有启用的跟单配置,确保账户在监听列表中 + accountOnChainMonitorService.addAccount(account) + } else { + // 没有启用的跟单配置,移除账户监听 + accountOnChainMonitorService.removeAccount(accountId) + } + } + /** * 重新启动监听(当跟单关系状态改变时调用) + * 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新 */ suspend fun restartMonitoring() { - // 只使用轮询,不使用WebSocket + // 停止所有监听 + onChainWsService.stop() pollingService.stop() delay(1000) // 等待1秒 startMonitoring() diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingWebSocketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingWebSocketService.kt index f3ff981..825be9f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingWebSocketService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/CopyTradingWebSocketService.kt @@ -22,15 +22,14 @@ import java.util.concurrent.ConcurrentHashMap @Service class CopyTradingWebSocketService( private val copyOrderTrackingService: CopyOrderTrackingService, - private val templateRepository: CopyTradingTemplateRepository + private val templateRepository: CopyTradingTemplateRepository, + private val gson: Gson ) { private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java) @Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}") private var websocketUrl: String = "wss://ws-live-data.polymarket.com" - - private val gson = Gson() private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) // 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsService.kt new file mode 100644 index 0000000..3ee5e7c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsService.kt @@ -0,0 +1,177 @@ +package com.wrbug.polymarketbot.service.copytrading.monitor + +import com.wrbug.polymarketbot.api.* +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.LeaderRepository +import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService +import com.wrbug.polymarketbot.util.RetrofitFactory +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap + +/** + * 链上 WebSocket 监听服务 + * 通过统一服务订阅 Leader 的链上交易 + */ +@Service +class OnChainWsService( + private val unifiedOnChainWsService: UnifiedOnChainWsService, + private val retrofitFactory: RetrofitFactory, + private val copyOrderTrackingService: CopyOrderTrackingService, + private val leaderRepository: LeaderRepository +) { + + private val logger = LoggerFactory.getLogger(OnChainWsService::class.java) + + // 存储需要监听的Leader:leaderId -> Leader + private val monitoredLeaders = ConcurrentHashMap() + + /** + * 启动链上 WebSocket 监听 + * 通过统一服务订阅所有 Leader + */ + fun start(leaders: List) { + // 如果没有 Leader,取消所有订阅 + if (leaders.isEmpty()) { + logger.info("没有需要监听的 Leader,取消所有订阅") + stop() + return + } + + // 更新 Leader 列表 + monitoredLeaders.clear() + leaders.forEach { leader -> + addLeader(leader) + } + } + + /** + * 添加Leader监听 + * 通过统一服务订阅该 Leader 的地址 + */ + fun addLeader(leader: Leader) { + if (leader.id == null) { + logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}") + return + } + + val leaderId = leader.id!! + + // 如果已经在监听列表中,不重复添加 + if (monitoredLeaders.containsKey(leaderId)) { + logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})") + return + } + + monitoredLeaders[leaderId] = leader + + // 通过统一服务订阅 + val subscriptionId = "LEADER_$leaderId" + unifiedOnChainWsService.subscribe( + subscriptionId = subscriptionId, + address = leader.leaderAddress, + entityType = "LEADER", + entityId = leaderId, + callback = { txHash, httpClient, rpcApi -> + handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi) + } + ) + + logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})") + } + + /** + * 处理 Leader 的交易 + */ + private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) { + val leader = monitoredLeaders[leaderId] ?: return + + try { + // 获取交易 receipt + val receiptRequest = JsonRpcRequest( + method = "eth_getTransactionReceipt", + params = listOf(txHash) + ) + + val receiptResponse = rpcApi.call(receiptRequest) + if (!receiptResponse.isSuccessful || receiptResponse.body() == null) { + return + } + + val receiptRpcResponse = receiptResponse.body()!! + if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) { + return + } + + // 使用 Gson 解析 receipt JSON + val receiptJson = receiptRpcResponse.result.asJsonObject + + // 获取区块号和时间戳 + val blockNumber = receiptJson.get("blockNumber")?.asString + val blockTimestamp = if (blockNumber != null) { + OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi) + } else { + null + } + + // 解析 receipt 中的 Transfer 日志 + val logs = receiptJson.getAsJsonArray("logs") ?: return + val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs) + + // 解析交易信息 + val trade = OnChainWsUtils.parseTradeFromTransfers( + txHash = txHash, + timestamp = blockTimestamp, + walletAddress = leader.leaderAddress, + erc20Transfers = erc20Transfers, + erc1155Transfers = erc1155Transfers, + retrofitFactory = retrofitFactory + ) + + if (trade != null) { + // 调用 processTrade 处理交易 + copyOrderTrackingService.processTrade( + leaderId = leaderId, + trade = trade, + source = "onchain-ws" + ) + } + } catch (e: Exception) { + logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e) + } + } + + /** + * 移除Leader监听 + * 取消该 Leader 的订阅 + */ + fun removeLeader(leaderId: Long) { + monitoredLeaders.remove(leaderId) + + // 通过统一服务取消订阅 + val subscriptionId = "LEADER_$leaderId" + unifiedOnChainWsService.unsubscribe(subscriptionId) + + logger.info("移除 Leader 监听: leaderId=$leaderId") + } + + /** + * 停止监听 + */ + fun stop() { + // 取消所有 Leader 的订阅 + val leaderIds = monitoredLeaders.keys.toList() + for (leaderId in leaderIds) { + removeLeader(leaderId) + } + monitoredLeaders.clear() + } + + @PreDestroy + fun destroy() { + stop() + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt new file mode 100644 index 0000000..7ba35c6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt @@ -0,0 +1,377 @@ +package com.wrbug.polymarketbot.service.copytrading.monitor + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.reflect.TypeToken +import com.wrbug.polymarketbot.api.* +import com.wrbug.polymarketbot.service.system.RpcNodeService +import com.wrbug.polymarketbot.util.RetrofitFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import java.math.BigInteger + +/** + * 链上 WebSocket 工具类 + * 提取公共的交易解析、工具函数等逻辑 + */ +object OnChainWsUtils { + + private val logger = LoggerFactory.getLogger(OnChainWsUtils::class.java) + + // 创建 Gson 实例(与 GsonConfig 中的配置一致,使用 lenient 模式) + private val gson: Gson = GsonBuilder() + .setLenient() + .create() + + /** + * 解析 JSON 字符串数组 + * @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]" + * @return 字符串列表,如果解析失败返回空列表 + */ + private fun parseStringArray(jsonString: String?): List { + if (jsonString.isNullOrBlank()) { + return emptyList() + } + + return try { + val listType = object : TypeToken>() {}.type + gson.fromJson>(jsonString, listType) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + // 合约地址 + const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045" + const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62" + const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb" + + /** + * ERC20 Transfer 数据类 + */ + data class Erc20Transfer( + val from: String, + val to: String, + val value: BigInteger + ) + + /** + * ERC1155 Transfer 数据类 + */ + data class Erc1155Transfer( + val from: String, + val to: String, + val tokenId: BigInteger, + val value: BigInteger + ) + + /** + * 市场信息数据类 + */ + data class MarketInfo( + val conditionId: String, + val outcomeIndex: Int?, // 可空,因为可能找不到对应的 tokenId + val outcome: String? + ) + + /** + * 解析 receipt 中的 Transfer 日志 + */ + fun parseReceiptTransfers(logs: JsonArray): Pair, List> { + val erc20 = mutableListOf() + val erc1155 = mutableListOf() + + for (logElement in logs) { + val log = logElement.asJsonObject + val address = log.get("address")?.asString?.lowercase() ?: continue + val topicsArray = log.getAsJsonArray("topics") ?: continue + val topics = topicsArray.mapNotNull { it.asString } + if (topics.isEmpty()) continue + + val t0 = topics[0].lowercase() + val data = log.get("data")?.asString ?: "0x" + + // USDC ERC20 Transfer + if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) { + val from = topicToAddress(topics[1]) + val to = topicToAddress(topics[2]) + val value = hexToBigInt(data) + erc20.add(Erc20Transfer(from, to, value)) + continue + } + + // ERC1155 TransferSingle + if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) { + val from = topicToAddress(topics[2]) + val to = topicToAddress(topics[3]) + val bytes = bytesFromHex(data) + if (bytes.size >= 64) { + val tokenId = sliceBigInt32(bytes, 0) + val value = sliceBigInt32(bytes, 32) + erc1155.add(Erc1155Transfer(from, to, tokenId, value)) + } + continue + } + + // ERC1155 TransferBatch + if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) { + val from = topicToAddress(topics[2]) + val to = topicToAddress(topics[3]) + val bytes = bytesFromHex(data) + if (bytes.size < 64) continue + + val offIds = sliceBigInt32(bytes, 0).toInt() + val offVals = sliceBigInt32(bytes, 32).toInt() + if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue + + val nIds = sliceBigInt32(bytes, offIds).toInt() + val nVals = sliceBigInt32(bytes, offVals).toInt() + if (nIds != nVals) continue + + val idsStart = offIds + 32 + val valsStart = offVals + 32 + for (i in 0 until nIds) { + val ib = idsStart + i * 32 + val vb = valsStart + i * 32 + if (ib + 32 > bytes.size || vb + 32 > bytes.size) break + val tokenId = sliceBigInt32(bytes, ib) + val value = sliceBigInt32(bytes, vb) + erc1155.add(Erc1155Transfer(from, to, tokenId, value)) + } + } + } + + return Pair(erc20, erc1155) + } + + /** + * 从 Transfer 日志解析交易信息 + */ + suspend fun parseTradeFromTransfers( + txHash: String, + timestamp: Long?, + walletAddress: String, + erc20Transfers: List, + erc1155Transfers: List, + retrofitFactory: RetrofitFactory + ): TradeResponse? { + val wallet = walletAddress.lowercase() + + // 计算 USDC 流入和流出 + val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet } + .fold(BigInteger.ZERO) { acc, t -> acc + t.value } + val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet } + .fold(BigInteger.ZERO) { acc, t -> acc + t.value } + + // 计算 ERC1155 流入和流出(按 tokenId 聚合) + val inById = mutableMapOf() + val outById = mutableMapOf() + for (t in erc1155Transfers) { + if (t.to.lowercase() == wallet) { + inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value + } + if (t.from.lowercase() == wallet) { + outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value + } + } + + // 找到最大的流入和流出 tokenId + fun best(map: Map): Pair = + map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO) + + val (bestInId, bestInVal) = best(inById) + val (bestOutId, bestOutVal) = best(outById) + + // 判断交易方向 + var side: String? = null + var asset: BigInteger? = null + var sizeRaw = BigInteger.ZERO + var usdcRaw = BigInteger.ZERO + + if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) { + // BUY: 收到 token,支付 USDC + side = "BUY" + asset = bestInId + sizeRaw = bestInVal + usdcRaw = usdcOut + } else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) { + // SELL: 卖出 token,收到 USDC + side = "SELL" + asset = bestOutId + sizeRaw = bestOutVal + usdcRaw = usdcIn + } else { + // 无法判断交易方向 + return null + } + + // 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数) + val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN) + val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN) + val price = if (size.signum() > 0) { + usdcSize.divide(size, 8, java.math.RoundingMode.DOWN) + } else { + return null + } + + // 尝试通过 Gamma API 查询市场信息(通过 tokenId) + val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory) + + // 创建 TradeResponse + return TradeResponse( + id = txHash, + market = marketInfo?.conditionId ?: "", + side = side, + price = price.toPlainString(), + size = size.toPlainString(), + timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(), + user = walletAddress, + outcomeIndex = marketInfo?.outcomeIndex, + outcome = marketInfo?.outcome + ) + } + + /** + * 通过 Gamma API 查询市场信息(通过 tokenId) + * 使用 Retrofit 接口,支持 clob_token_ids 参数 + */ + suspend fun fetchMarketByTokenId(tokenId: String, retrofitFactory: RetrofitFactory): MarketInfo? { + return try { + val gammaApi = retrofitFactory.createGammaApi() + val marketsResponse = gammaApi.listMarkets( + conditionIds = null, + clobTokenIds = listOf(tokenId), + includeTag = null + ) + + if (!marketsResponse.isSuccessful || marketsResponse.body() == null) { + return null + } + + val markets = marketsResponse.body()!! + val market = markets.firstOrNull() + + if (market == null) { + return null + } + + // 解析 clobTokenIds(可能是 JSON 字符串或数组) + val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids + val clobTokenIds = when { + clobTokenIdsRaw == null -> null + else -> { + // 解析 JSON 字符串 + parseStringArray(clobTokenIdsRaw) + } + } + + // 解析 outcomes(可能是 JSON 字符串或数组) + val outcomes = parseStringArray(market.outcomes) + + // 查找 tokenId 在 clobTokenIds 中的索引 + val outcomeIndex = clobTokenIds?.indexOfFirst { token -> + token.equals(tokenId, ignoreCase = true) + }?.takeIf { it >= 0 } + + // 获取 outcome 名称 + val outcome = if (outcomeIndex != null && outcomes.isNotEmpty() && outcomeIndex < outcomes.size) { + outcomes[outcomeIndex] + } else { + null + } + + val conditionId = market.conditionId ?: return null + + MarketInfo( + conditionId = conditionId, + outcomeIndex = outcomeIndex, + outcome = outcome + ) + } catch (e: Exception) { + logger.warn("查询市场信息失败: tokenId=$tokenId, error=${e.message}") + null + } + } + + /** + * 获取区块时间戳 + */ + suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? { + return try { + val blockRequest = JsonRpcRequest( + method = "eth_getBlockByNumber", + params = listOf(blockNumber, false) + ) + + val blockResponse = rpcApi.call(blockRequest) + if (blockResponse.isSuccessful && blockResponse.body() != null) { + val blockRpcResponse = blockResponse.body()!! + if (blockRpcResponse.error == null && blockRpcResponse.result != null) { + val blockJson = blockRpcResponse.result.asJsonObject + val timestampHex = blockJson.get("timestamp")?.asString + if (timestampHex != null) { + BigInteger(timestampHex.removePrefix("0x"), 16).toLong() * 1000 // 转换为毫秒 + } else { + null + } + } else { + null + } + } else { + null + } + } catch (e: Exception) { + logger.warn("获取区块时间戳失败: blockNumber=$blockNumber, error=${e.message}") + null + } + } + + /** + * 工具函数:地址转 topic(32字节,左对齐) + */ + fun addressToTopic32(address: String): String { + val clean = address.removePrefix("0x").lowercase() + return "0x" + clean.padStart(64, '0') + } + + /** + * 工具函数:topic 转地址 + */ + fun topicToAddress(topic: String): String { + val clean = topic.removePrefix("0x").lowercase() + return "0x" + clean.takeLast(40) + } + + /** + * 工具函数:十六进制转 BigInteger + */ + fun hexToBigInt(hex: String): BigInteger { + val clean = hex.removePrefix("0x") + return if (clean.isBlank()) BigInteger.ZERO else BigInteger(clean, 16) + } + + /** + * 工具函数:十六进制转字节数组 + */ + fun bytesFromHex(hex: String): ByteArray { + val clean = hex.removePrefix("0x") + return ByteArray(clean.length / 2) { i -> + clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } + + /** + * 工具函数:从字节数组切片 BigInteger(32字节) + */ + fun sliceBigInt32(bytes: ByteArray, offset: Int): BigInteger { + if (offset + 32 > bytes.size) return BigInteger.ZERO + val slice = bytes.sliceArray(offset until offset + 32) + return BigInteger(1, slice) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/UnifiedOnChainWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/UnifiedOnChainWsService.kt new file mode 100644 index 0000000..22d02ab --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/UnifiedOnChainWsService.kt @@ -0,0 +1,565 @@ +package com.wrbug.polymarketbot.service.copytrading.monitor + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.wrbug.polymarketbot.api.* +import com.wrbug.polymarketbot.service.system.RpcNodeService +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.createClient +import com.wrbug.polymarketbot.util.getProxyConfig +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap + +/** + * 统一的链上 WebSocket 服务 + * 管理唯一的 WebSocket 连接,其他服务通过订阅的方式接收链上事件 + */ +@Service +class UnifiedOnChainWsService( + private val rpcNodeService: RpcNodeService, + private val retrofitFactory: RetrofitFactory, + private val gson: Gson +) { + + private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java) + + @Value("\${copy.trading.onchain.ws.reconnect.delay:3000}") + private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒 + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // WebSocket 连接(唯一) + private var webSocket: WebSocket? = null + @Volatile + private var isConnected = false + + // 订阅ID计数器(用于请求 ID) + private var requestIdCounter = 0 + + // 连接任务(确保只有一个连接任务在运行) + private var connectionJob: Job? = null + + // 存储所有订阅:subscriptionId -> 订阅信息 + private val subscriptions = ConcurrentHashMap() + + // 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId + // 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅 + private val requestIdToSubscriptionId = ConcurrentHashMap() + + // 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId + // 用于在收到日志通知时,知道是哪个订阅 + private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap() + + /** + * 订阅信息 + */ + data class SubscriptionInfo( + val subscriptionId: String, // 订阅的唯一标识 + val address: String, // 要监听的地址(Leader 地址或账户代理地址) + val entityType: String, // 实体类型:LEADER 或 ACCOUNT + val entityId: Long, // 实体 ID(Leader ID 或 Account ID) + val callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit // 回调函数 + ) + + /** + * 订阅地址监听 + * @param subscriptionId 订阅的唯一标识(建议格式:"{entityType}_{entityId}") + * @param address 要监听的地址(Leader 地址或账户代理地址) + * @param entityType 实体类型:LEADER 或 ACCOUNT + * @param entityId 实体 ID(Leader ID 或 Account ID) + * @param callback 回调函数,当检测到该地址的交易时调用 + * @return 是否订阅成功 + */ + fun subscribe( + subscriptionId: String, + address: String, + entityType: String, + entityId: Long, + callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit + ): Boolean { + try { + // 如果已经订阅,先取消 + if (subscriptions.containsKey(subscriptionId)) { + unsubscribe(subscriptionId) + } + + // 创建订阅信息 + val subscription = SubscriptionInfo( + subscriptionId = subscriptionId, + address = address.lowercase(), + entityType = entityType, + entityId = entityId, + callback = callback + ) + + subscriptions[subscriptionId] = subscription + + // 如果已连接,立即订阅 + if (isConnected) { + scope.launch { + subscribeAddress(subscription) + } + } else { + // 如果未连接,启动连接 + startConnection() + } + + logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId") + return true + } catch (e: Exception) { + logger.error("订阅地址监听失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e) + return false + } + } + + /** + * 取消订阅 + */ + fun unsubscribe(subscriptionId: String) { + val subscription = subscriptions.remove(subscriptionId) + + if (subscription != null && isConnected) { + // 取消该订阅的所有 RPC 订阅 + scope.launch { + // 查找该订阅的所有 RPC subscriptionId + val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries + .filter { it.value == subscriptionId } + .map { it.key } + + for (rpcSubId in rpcSubscriptionIds) { + unsubscribeRpc(rpcSubId) + rpcSubscriptionIdToSubscriptionId.remove(rpcSubId) + } + } + + logger.info("取消订阅: subscriptionId=$subscriptionId") + } + + // 如果没有订阅了,停止连接 + if (subscriptions.isEmpty()) { + stop() + } + } + + /** + * 启动连接(如果还没有连接) + */ + private fun startConnection() { + // 如果没有订阅,不启动连接 + if (subscriptions.isEmpty()) { + return + } + + // 如果连接任务已经在运行,不重复启动 + if (connectionJob != null && connectionJob!!.isActive) { + return + } + + // 启动连接任务 + connectionJob = scope.launch { + startConnectionLoop() + } + } + + /** + * 启动连接循环 + */ + private suspend fun startConnectionLoop() { + while (scope.isActive) { + try { + // 如果没有订阅,停止连接 + if (subscriptions.isEmpty()) { + logger.info("没有订阅,停止连接") + stop() + break + } + + // 如果已经连接,等待断开 + if (isConnected && webSocket != null) { + waitForDisconnect() + continue + } + + // 获取可用的 RPC 节点 + val wsUrl = rpcNodeService.getWsUrl() + val httpUrl = rpcNodeService.getHttpUrl() + + if (wsUrl.isBlank() || httpUrl.isBlank()) { + logger.warn("没有可用的 RPC 节点,等待重试...") + delay(reconnectDelay) + continue + } + + logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)") + + // 创建 HTTP 客户端(用于 RPC 调用) + val httpClient = createHttpClient() + + // 创建 RPC API 客户端 + val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl) + + // 连接 WebSocket + connectWebSocket(wsUrl, httpClient, rpcApi) + + // 等待连接建立 + waitForConnect() + + // 如果连接成功,订阅所有地址 + if (isConnected) { + logger.info("WebSocket 连接已建立,开始订阅") + for (subscription in subscriptions.values) { + subscribeAddress(subscription) + } + + // 等待连接断开 + waitForDisconnect() + } + + // 连接断开后,如果没有订阅了,不再重连 + if (subscriptions.isEmpty()) { + logger.info("没有订阅,停止重连") + break + } + + // 等待后重连 + logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连") + delay(reconnectDelay) + + } catch (e: Exception) { + logger.error("连接异常: ${e.message}", e) + delay(reconnectDelay) + } + } + } + + /** + * 创建 HTTP 客户端 + */ + private fun createHttpClient(): OkHttpClient { + val proxy = getProxyConfig() + val builder = createClient() + + if (proxy != null) { + builder.proxy(proxy) + } + + return builder.build() + } + + /** + * 连接 WebSocket + */ + private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) { + // 先关闭旧连接 + webSocket?.close(1000, "重新连接") + webSocket = null + isConnected = false + + val request = Request.Builder() + .url(wsUrl) + .build() + + webSocket = httpClient.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { + isConnected = true + logger.info("链上 WebSocket 连接成功") + } + + override fun onMessage(webSocket: WebSocket, text: String) { + scope.launch { + handleMessage(text, httpClient, rpcApi) + } + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + scope.launch { + handleMessage(bytes.utf8(), httpClient, rpcApi) + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + isConnected = false + logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + isConnected = false + logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason") + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + logger.error("链上 WebSocket 连接失败: ${t.message}", t) + isConnected = false + } + }) + } + + /** + * 等待连接建立 + */ + private suspend fun waitForConnect() { + var waited = 0L + val timeout = 15000L // 15秒超时 + + while (!isConnected && waited < timeout) { + delay(100) + waited += 100 + } + + if (!isConnected) { + logger.warn("WebSocket 连接超时,等待重连") + } + } + + /** + * 等待连接断开 + */ + private suspend fun waitForDisconnect() { + while (isConnected && scope.isActive) { + delay(1000) + } + } + + /** + * 订阅地址(为每个地址订阅 6 个事件) + */ + private suspend fun subscribeAddress(subscription: SubscriptionInfo) { + if (webSocket == null || !isConnected) { + return + } + + val address = subscription.address + val walletTopic = OnChainWsUtils.addressToTopic32(address) + val subscriptionId = subscription.subscriptionId + + try { + // 订阅 USDC Transfer (from wallet) + subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId) + + // 订阅 USDC Transfer (to wallet) + subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId) + + // 订阅 ERC1155 TransferSingle (from wallet) + subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId) + + // 订阅 ERC1155 TransferSingle (to wallet) + subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId) + + // 订阅 ERC1155 TransferBatch (from wallet) + subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId) + + // 订阅 ERC1155 TransferBatch (to wallet) + subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId) + + logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address") + } catch (e: Exception) { + logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e) + } + } + + /** + * 订阅日志 + */ + private fun subscribeLogs(address: String, topics: List, subscriptionId: String) { + val ws = webSocket ?: return + + val params = mapOf( + "address" to address.lowercase(), + "topics" to topics.filterNotNull() + ) + + val requestId = ++requestIdCounter + requestIdToSubscriptionId[requestId] = subscriptionId + + val request = mapOf( + "jsonrpc" to "2.0", + "id" to requestId, + "method" to "eth_subscribe", + "params" to listOf("logs", params) + ) + + val message = gson.toJson(request) + ws.send(message) + } + + /** + * 取消 RPC 订阅 + */ + private fun unsubscribeRpc(rpcSubscriptionId: String) { + val ws = webSocket ?: return + + val requestId = ++requestIdCounter + val request = mapOf( + "jsonrpc" to "2.0", + "id" to requestId, + "method" to "eth_unsubscribe", + "params" to listOf(rpcSubscriptionId) + ) + + val message = gson.toJson(request) + ws.send(message) + } + + /** + * 处理 WebSocket 消息 + */ + private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) { + try { + val message = gson.fromJson(text, JsonObject::class.java) + + // 处理订阅响应 + if (message.has("result") && message.has("id")) { + val requestId = message.get("id")?.asInt + val rpcSubscriptionId = message.get("result")?.asString + + if (requestId != null && rpcSubscriptionId != null) { + val subscriptionId = requestIdToSubscriptionId.remove(requestId) + if (subscriptionId != null) { + // 保存 RPC subscriptionId 到订阅的映射 + rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId + logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId") + } + } + return + } + + // 处理日志通知 + if (message.has("params")) { + val params = message.getAsJsonObject("params") + val subscriptionIdParam = params.get("subscription")?.asString + val result = params.getAsJsonObject("result") + + if (result != null) { + val txHash = result.get("transactionHash")?.asString + if (txHash != null && subscriptionIdParam != null) { + // 根据 RPC subscriptionId 找到对应的订阅 + val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam] + if (subscriptionId != null) { + // 处理交易,分发给对应的订阅者 + processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi) + } else { + // 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅 + processTransaction(txHash, httpClient, rpcApi) + } + } + } + } + } catch (e: Exception) { + logger.error("处理 WebSocket 消息失败: ${e.message}", e) + } + } + + /** + * 处理交易(为特定订阅) + * 直接调用订阅的回调 + */ + private suspend fun processTransactionForSubscription( + txHash: String, + subscriptionId: String, + httpClient: OkHttpClient, + rpcApi: EthereumRpcApi + ) { + val subscription = subscriptions[subscriptionId] ?: return + + try { + subscription.callback(txHash, httpClient, rpcApi) + } catch (e: Exception) { + logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e) + } + } + + /** + * 处理交易(为所有订阅,用于兼容) + * 解析交易中的 Transfer 事件,分发给所有订阅者 + */ + private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) { + try { + // 获取交易 receipt + val receiptRequest = JsonRpcRequest( + method = "eth_getTransactionReceipt", + params = listOf(txHash) + ) + + val receiptResponse = rpcApi.call(receiptRequest) + if (!receiptResponse.isSuccessful || receiptResponse.body() == null) { + return + } + + val receiptRpcResponse = receiptResponse.body()!! + if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) { + return + } + + // 使用 Gson 解析 receipt JSON + val receiptJson = receiptRpcResponse.result.asJsonObject + + // 解析 receipt 中的 Transfer 日志 + val logs = receiptJson.getAsJsonArray("logs") ?: return + val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs) + + // 为每个订阅检查是否匹配,如果匹配则调用回调 + for (subscription in subscriptions.values) { + val address = subscription.address + + // 检查该地址是否参与了交易(通过检查 Transfer 日志) + val isInvolved = erc20Transfers.any { + it.from.lowercase() == address || it.to.lowercase() == address + } || erc1155Transfers.any { + it.from.lowercase() == address || it.to.lowercase() == address + } + + if (isInvolved) { + // 该地址参与了交易,调用回调 + try { + subscription.callback(txHash, httpClient, rpcApi) + } catch (e: Exception) { + logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e) + } + } + } + } catch (e: Exception) { + logger.error("处理交易失败: txHash=$txHash, ${e.message}", e) + } + } + + /** + * 停止连接 + */ + fun stop() { + connectionJob?.cancel() + connectionJob = null + + // 关闭 WebSocket 连接 + webSocket?.close(1000, "停止监听") + webSocket = null + isConnected = false + + // 清空订阅信息 + subscriptions.clear() + requestIdToSubscriptionId.clear() + rpcSubscriptionIdToSubscriptionId.clear() + } + + @PostConstruct + fun init() { + // 服务启动时不自动连接,等待有订阅时再连接 + logger.info("统一链上 WebSocket 服务已初始化") + } + + @PreDestroy + fun destroy() { + stop() + scope.cancel() + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt index 90a0dde..bff4c60 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt @@ -100,8 +100,9 @@ class OrderSigningService { return OrderAmounts(makerAmount.toString(), takerAmount.toString()) } else { // SELL: makerAmount = size (shares), takerAmount = price * size (USDC) - // makerAmount 是 shares 数量,最多 4 位小数 - // takerAmount 是 USDC 金额,需要使用原始价格计算(与SDK保持一致) + // 根据 Polymarket API 要求: + // - makerAmount (shares) 最多 2 位小数 + // - takerAmount (USDC) 最多 4 位小数 val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size) // takerAmount = price * size,使用原始价格计算(不使用舍入后的价格) @@ -109,16 +110,15 @@ class OrderSigningService { // 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464 val rawTakerAmt = rawMakerAmt.multiply(priceDecimal) - // 确保 makerAmount 精度(shares,最多 4 位小数) - val finalMakerAmt = roundDown(rawMakerAmt, TAKER_AMOUNT_DECIMALS) + // 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求) + val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS) - // takerAmount 不进行舍入,直接使用精确计算结果转换为 wei - // parseUnits 会将 BigDecimal 转换为 wei(6 位小数),自动处理精度 - // 使用原始价格计算可以确保与SDK的结果一致 + // 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求) + val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS) // 转换为 wei(6 位小数) val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS) - val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS) + val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS) return OrderAmounts(makerAmount.toString(), takerAmount.toString()) } @@ -172,25 +172,15 @@ class OrderSigningService { // 5. 确保 maker 地址也是小写格式 val makerAddressLower = makerAddress.lowercase() - // 打印签名前的订单参数 - logger.info("========== 订单签名前参数 ==========") - logger.info("订单方向: $side") - logger.info("价格: $price") - logger.info("数量: $size") - logger.info("Token ID: $tokenId") - logger.info("Maker 地址: $makerAddressLower") - logger.info("Signer 地址: $signerAddress") - logger.info("Taker 地址: $taker") - logger.info("Maker Amount (wei): ${amounts.makerAmount}") - logger.info("Taker Amount (wei): ${amounts.takerAmount}") - logger.info("Salt: $salt") - logger.info("Expiration: $expiration") - logger.info("Nonce: $nonce") - logger.info("Fee Rate BPS: $feeRateBps") - logger.info("Signature Type: $signatureType") - logger.info("Exchange Contract: $EXCHANGE_CONTRACT") - logger.info("Chain ID: $CHAIN_ID") - logger.info("====================================") + // 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露) + logger.debug("========== 订单签名前参数 ==========") + logger.debug("订单方向: $side, 价格: $price, 数量: $size") + logger.debug("Token ID: $tokenId") + logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}") + logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}") + logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}") + logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps") + logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID") // 6. 构建订单数据并签名 val signature = signOrder( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index fd33965..73a6b21 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -8,10 +8,13 @@ import com.wrbug.polymarketbot.repository.* import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.* import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DuplicateKeyException import java.sql.SQLException +import java.util.concurrent.ConcurrentHashMap import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService @@ -34,7 +37,6 @@ open class CopyOrderTrackingService( private val sellMatchRecordRepository: SellMatchRecordRepository, private val sellMatchDetailRepository: SellMatchDetailRepository, private val processedTradeRepository: ProcessedTradeRepository, - private val failedTradeRepository: FailedTradeRepository, private val filteredOrderRepository: FilteredOrderRepository, private val copyTradingRepository: CopyTradingRepository, private val accountRepository: AccountRepository, @@ -53,11 +55,22 @@ open class CopyOrderTrackingService( // 协程作用域(用于异步发送通知) private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // 使用 Mutex 保证线程安全(按交易ID锁定) + private val tradeMutexMap = ConcurrentHashMap() + // 订单创建重试配置 companion object { private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试) private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒) } + + /** + * 获取或创建 Mutex(按交易ID) + */ + private fun getMutex(leaderId: Long, tradeId: String): Mutex { + val key = "${leaderId}_${tradeId}" + return tradeMutexMap.getOrPut(key) { Mutex() } + } /** * 解密账户私钥 @@ -102,24 +115,23 @@ open class CopyOrderTrackingService( /** * 处理交易事件(WebSocket 或轮询) * 根据交易方向调用相应的处理方法 + * 使用 Mutex 保证线程安全(单实例部署) */ @Transactional suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result { - return try { + // 获取该交易的 Mutex(按交易ID锁定,不同交易可以并行处理) + val mutex = getMutex(leaderId, trade.id) + + return mutex.withLock { + try { // 1. 检查是否已处理(去重,包括失败状态) val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) if (existingProcessed != null) { if (existingProcessed.status == "FAILED") { - return Result.success(Unit) + return@withLock Result.success(Unit) } - return Result.success(Unit) - } - - // 检查是否已记录为失败交易 - val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) - if (failedTrade != null) { - return Result.success(Unit) + return@withLock Result.success(Unit) } // 2. 处理交易逻辑 @@ -137,11 +149,11 @@ open class CopyOrderTrackingService( "处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}", result.exceptionOrNull() ) - return result + return@withLock result } // 3. 标记为已处理(成功状态) - // 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突 + // 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底 try { val processed = ProcessedTrade( leaderId = leaderId, @@ -153,18 +165,16 @@ open class CopyOrderTrackingService( ) processedTradeRepository.save(processed) } catch (e: Exception) { - // 检查是否是唯一键冲突异常(可能是 DataIntegrityViolationException、DuplicateKeyException 或 SQLException) + // 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底) if (isUniqueConstraintViolation(e)) { - // 唯一约束冲突,说明已经处理过了(可能是并发请求) - // 再次检查确认状态 val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) if (existing != null) { if (existing.status == "FAILED") { logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}") - return Result.success(Unit) + return@withLock Result.success(Unit) } logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}") - return Result.success(Unit) + return@withLock Result.success(Unit) } else { // 如果检查不到,可能是事务隔离级别问题,等待一下再查询 delay(100) @@ -172,15 +182,13 @@ open class CopyOrderTrackingService( processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) if (existingAfterDelay != null) { logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}") - return Result.success(Unit) + return@withLock Result.success(Unit) } - // 如果还是查询不到,记录警告但不抛出异常(可能是其他约束冲突) logger.warn( "保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", e ) - // 不抛出异常,避免影响其他交易的处理 - return Result.success(Unit) + return@withLock Result.success(Unit) } } else { // 其他类型的异常,重新抛出 @@ -192,6 +200,7 @@ open class CopyOrderTrackingService( } catch (e: Exception) { logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) Result.failure(e) + } } } @@ -241,11 +250,30 @@ open class CopyOrderTrackingService( } val tokenId = tokenIdResult.getOrNull() ?: continue + // 先计算跟单金额(用于仓位检查) + // 注意:这里先计算金额,即使后续被过滤也会记录 + val tradePrice = trade.price.toSafeBigDecimal() + val buyQuantity = try { + calculateBuyQuantity(trade, copyTrading) + } catch (e: Exception) { + logger.warn("计算买入数量失败: ${e.message}", e) + continue + } + + // 计算跟单金额(USDC)= 买入数量 × 价格 + val copyOrderAmount = buyQuantity.multi(tradePrice) + // 过滤条件检查(在计算订单参数之前) // 传入 Leader 交易价格,用于价格区间检查 + // 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位) // 订单簿只请求一次,返回给后续逻辑使用 - val tradePrice = trade.price.toSafeBigDecimal() - val filterResult = filterService.checkFilters(copyTrading, tokenId, tradePrice = tradePrice) + val filterResult = filterService.checkFilters( + copyTrading, + tokenId, + tradePrice = tradePrice, + copyOrderAmount = copyOrderAmount, + marketId = trade.market + ) val orderbook = filterResult.orderbook // 获取订单簿(如果需要) if (!filterResult.isPassed) { logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterResult.reason}") @@ -338,9 +366,8 @@ open class CopyOrderTrackingService( continue } - // 计算买入数量 - val buyQuantity = calculateBuyQuantity(trade, copyTrading) - + // 买入数量已在过滤检查前计算,这里直接使用 + // 如果数量为0或负数,跳过 if (buyQuantity.lte(BigDecimal.ZERO)) { logger.warn("计算出的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}") continue @@ -457,27 +484,6 @@ open class CopyOrderTrackingService( if (createOrderResult.isFailure) { // 提取错误信息(只保留 code 和 errorBody) val exception = createOrderResult.exceptionOrNull() - val errorMsg = buildFullErrorMessage( - exception, - "BUY", - buyPrice.toString(), - finalBuyQuantity.toString(), - trade.id - ) - - // 记录失败交易到数据库 - // retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数 - recordFailedTrade( - leaderId = leaderId, - trade = trade, - copyTradingId = copyTrading.id!!, - accountId = copyTrading.accountId, - side = "BUY", - price = buyPrice.toString(), - size = finalBuyQuantity.toString(), - errorMessage = errorMsg, - retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次数 - ) // 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送) if (copyTrading.pushFailedOrders) { @@ -533,8 +539,15 @@ open class CopyOrderTrackingService( } val realOrderId = createOrderResult.getOrNull() ?: continue + + // 验证 orderId 格式(必须以 0x 开头的 16 进制) + if (!isValidOrderId(realOrderId)) { + logger.warn("买入订单ID格式无效,跳过保存: orderId=$realOrderId") + continue + } // 创建买入订单跟踪记录(使用真实订单ID,使用outcomeIndex) + // 先使用下单时的价格和数量作为临时值,等待轮询任务获取实际数据后再发送通知 val tracking = CopyOrderTracking( copyTradingId = copyTrading.id, accountId = copyTrading.accountId, @@ -544,94 +557,17 @@ open class CopyOrderTrackingService( outcomeIndex = trade.outcomeIndex, // 新增字段 buyOrderId = realOrderId, // 使用真实订单ID leaderBuyTradeId = trade.id, - quantity = finalBuyQuantity, // 使用最终数量(可能已调整) - price = buyPrice, + leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例) + quantity = finalBuyQuantity, // 使用最终数量(可能已调整),临时值 + price = buyPrice, // 使用下单价格,临时值 remainingQuantity = finalBuyQuantity, - status = "filled" + status = "filled", + notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送 ) copyOrderTrackingRepository.save(tracking) - // 发送订单成功通知(异步,不阻塞) - notificationScope.launch { - try { - // 获取市场信息(标题和slug) - val marketInfo = withContext(Dispatchers.IO) { - try { - val gammaApi = retrofitFactory.createGammaApi() - val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market)) - if (marketResponse.isSuccessful && marketResponse.body() != null) { - marketResponse.body()!!.firstOrNull() - } else { - null - } - } catch (e: Exception) { - logger.warn("获取市场信息失败: ${e.message}", e) - null - } - } - - val marketTitle = marketInfo?.question ?: trade.market - val marketSlug = marketInfo?.slug - - // 重新创建 CLOB API 客户端用于查询订单详情 - val apiSecret = try { - decryptApiSecret(account) - } catch (e: Exception) { - logger.warn("解密 API Secret 失败: ${e.message}", e) - null - } - val apiPassphrase = try { - decryptApiPassphrase(account) - } catch (e: Exception) { - logger.warn("解密 API Passphrase 失败: ${e.message}", e) - null - } - - val clobApiForQuery = if (apiSecret != null && apiPassphrase != null) { - retrofitFactory.createClobApi( - account.apiKey, - apiSecret, - apiPassphrase, - account.walletAddress - ) - } else { - null - } - - // 获取当前语言设置(从 LocaleContextHolder) - val locale = try { - org.springframework.context.i18n.LocaleContextHolder.getLocale() - } catch (e: Exception) { - java.util.Locale("zh", "CN") // 默认简体中文 - } - - // 获取 Leader 和跟单配置信息 - val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) - val leaderName = leader?.leaderName - val configName = copyTrading.configName - - telegramNotificationService?.sendOrderSuccessNotification( - orderId = realOrderId, - marketTitle = marketTitle, - marketId = trade.market, - marketSlug = marketSlug, - side = "BUY", - accountName = account.accountName, - walletAddress = account.walletAddress, - clobApi = clobApiForQuery, - apiKey = account.apiKey, - apiSecret = apiSecret, - apiPassphrase = apiPassphrase, - walletAddressForApi = account.walletAddress, - locale = locale, - leaderName = leaderName, - configName = configName - ) - } catch (e: Exception) { - logger.warn("发送订单成功通知失败: ${e.message}", e) - } - } + logger.info("买入订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realOrderId, copyTradingId=${copyTrading.id}") } catch (e: Exception) { logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e) // 继续处理下一个跟单关系 @@ -705,9 +641,113 @@ open class CopyOrderTrackingService( } } + /** + * 计算固定金额模式下的卖出数量 + * 根据未匹配订单的实际买入比例计算 + */ + private suspend fun calculateSellQuantityForFixedMode( + unmatchedOrders: List, + leaderSellQuantity: BigDecimal, + copyTrading: CopyTrading + ): BigDecimal { + if (unmatchedOrders.isEmpty()) { + return BigDecimal.ZERO + } + + // 获取 Leader 信息(用于查询 Leader 买入交易) + val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) + ?: run { + logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}") + return leaderSellQuantity.multi(copyTrading.copyRatio) + } + + // 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据) + // 注意:Polymarket CLOB API 的 /data/trades 接口是公开的,不需要认证 + val clobApi = retrofitFactory.createClobApiWithoutAuth() + + // 计算总比例:sum(跟单买入数量) / sum(Leader 买入数量) + // 优先使用存储的 leaderBuyQuantity,如果不存在则尝试查询 API(兼容旧数据) + var totalCopyQuantity = BigDecimal.ZERO + var totalLeaderQuantity = BigDecimal.ZERO + var successCount = 0 + var failCount = 0 + + logger.debug("开始计算固定金额模式卖出数量: copyTradingId=${copyTrading.id}, unmatchedOrdersCount=${unmatchedOrders.size}, leaderSellQuantity=$leaderSellQuantity") + + for (order in unmatchedOrders) { + val copyQty = order.quantity.toSafeBigDecimal() + var leaderQty: BigDecimal? = null + + // 优先使用存储的 leaderBuyQuantity + if (order.leaderBuyQuantity != null) { + leaderQty = order.leaderBuyQuantity.toSafeBigDecimal() + logger.debug("使用存储的 Leader 买入数量: copyOrderId=${order.buyOrderId}, copyQty=$copyQty, leaderQty=$leaderQty") + successCount++ + } else { + // 兼容旧数据:如果 leaderBuyQuantity 为空,尝试查询 API + logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}") + try { + val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId) + + if (tradesResponse.isSuccessful && tradesResponse.body() != null) { + val tradesData = tradesResponse.body()!!.data + if (tradesData.isNotEmpty()) { + val leaderBuyTrade = tradesData.firstOrNull() + if (leaderBuyTrade != null) { + leaderQty = leaderBuyTrade.size.toSafeBigDecimal() + logger.debug("从 API 查询到 Leader 买入数量: leaderBuyTradeId=${order.leaderBuyTradeId}, leaderQty=$leaderQty") + successCount++ + } else { + logger.warn("未找到 Leader 买入交易: leaderBuyTradeId=${order.leaderBuyTradeId}") + failCount++ + } + } else { + logger.warn("Leader 买入交易数据为空: leaderBuyTradeId=${order.leaderBuyTradeId}") + failCount++ + } + } else { + logger.warn("查询 Leader 买入交易失败: leaderBuyTradeId=${order.leaderBuyTradeId}, code=${tradesResponse.code()}") + failCount++ + } + } catch (e: Exception) { + logger.warn("查询 Leader 买入交易异常: leaderBuyTradeId=${order.leaderBuyTradeId}, error=${e.message}") + failCount++ + } + } + + // 如果成功获取到 Leader 买入数量,累加 + if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) { + totalCopyQuantity = totalCopyQuantity.add(copyQty) + totalLeaderQuantity = totalLeaderQuantity.add(leaderQty) + } else { + logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}") + } + } + + logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity") + + // 如果无法计算总比例(查询失败),使用默认比例 + if (totalLeaderQuantity.lte(BigDecimal.ZERO)) { + logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}") + return leaderSellQuantity.multi(copyTrading.copyRatio) + } + + // 计算实际比例:跟单买入数量 / Leader 买入数量 + val actualRatio = totalCopyQuantity.div(totalLeaderQuantity) + + // 计算需要卖出的数量:Leader 卖出数量 × 实际比例 + val needMatch = leaderSellQuantity.multi(actualRatio) + + logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch") + + return needMatch + } + /** * 卖出订单匹配 - * 统一按比例计算,不区分RATIO或FIXED模式 + * 根据 copyMode 计算卖出数量: + * - RATIO 模式:使用配置的 copyRatio + * - FIXED 模式:根据实际买入比例计算 * 实际创建卖出订单并记录匹配关系 * 注意:此方法在 @Transactional 方法中被调用,会自动继承事务 */ @@ -733,10 +773,7 @@ open class CopyOrderTrackingService( return } - // 2. 计算需要匹配的数量(统一按比例计算) - val needMatch = leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) - - // 3. 查找未匹配的买入订单(FIFO顺序) + // 2. 查找未匹配的买入订单(FIFO顺序) // 直接使用outcomeIndex匹配,而不是转换为YES/NO if (leaderSellTrade.outcomeIndex == null) { logger.warn("卖出交易缺少outcomeIndex,无法匹配: tradeId=${leaderSellTrade.id}, market=${leaderSellTrade.market}") @@ -754,6 +791,28 @@ open class CopyOrderTrackingService( return } + // 3. 计算需要匹配的数量 + // 对于 FIXED 模式,需要根据实际买入比例计算;对于 RATIO 模式,使用配置的 copyRatio + val needMatch = when (copyTrading.copyMode) { + "FIXED" -> { + // 固定金额模式:根据未匹配订单的实际比例计算 + // 需要查询每个订单对应的 Leader 买入交易,计算实际比例 + calculateSellQuantityForFixedMode( + unmatchedOrders = unmatchedOrders, + leaderSellQuantity = leaderSellTrade.size.toSafeBigDecimal(), + copyTrading = copyTrading + ) + } + "RATIO" -> { + // 比例模式:直接使用配置的 copyRatio + leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) + } + else -> { + logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式") + leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) + } + } + // 4. 获取tokenId(直接使用outcomeIndex,支持多元市场) val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex) if (tokenIdResult.isFailure) { @@ -814,9 +873,24 @@ open class CopyOrderTrackingService( return } - // 7. 解密私钥(在方法开始时解密一次,后续复用) + // 7. 解密 API 凭证 + val apiSecret = try { + decryptApiSecret(account) + } catch (e: Exception) { + logger.warn("解密 API 凭证失败,跳过创建卖出订单: accountId=${account.id}, error=${e.message}") + return + } + val apiPassphrase = try { + decryptApiPassphrase(account) + } catch (e: Exception) { + logger.warn("解密 API 凭证失败,跳过创建卖出订单: accountId=${account.id}, error=${e.message}") + return + } + + // 8. 解密私钥(在方法开始时解密一次,后续复用) val decryptedPrivateKey = decryptPrivateKey(account) - // 8. 创建并签名卖出订单 + + // 9. 创建并签名卖出订单 val signedOrder = try { orderSigningService.createAndSignOrder( privateKey = decryptedPrivateKey, @@ -835,7 +909,7 @@ open class CopyOrderTrackingService( return } - // 9. 构建订单请求 + // 10. 构建订单请求 // 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配 val orderRequest = NewOrderRequest( @@ -845,15 +919,15 @@ open class CopyOrderTrackingService( deferExec = false ) - // 10. 创建带认证的CLOB API客户端 + // 11. 创建带认证的CLOB API客户端(使用解密后的凭证) val clobApi = retrofitFactory.createClobApi( account.apiKey, - account.apiSecret, - account.apiPassphrase, + apiSecret, + apiPassphrase, account.walletAddress ) - // 11. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) + // 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) val createOrderResult = createOrderWithRetry( clobApi = clobApi, @@ -869,32 +943,27 @@ open class CopyOrderTrackingService( ) if (createOrderResult.isFailure) { - // 创建订单失败,记录到失败表 + // 创建订单失败,记录错误日志 val exception = createOrderResult.exceptionOrNull() - val errorMsg = buildFullErrorMessage( - exception, - "SELL", - sellPrice.toString(), - totalMatched.toString(), - leaderSellTrade.id - ) - recordFailedTrade( - leaderId = copyTrading.leaderId, - trade = leaderSellTrade, - copyTradingId = copyTrading.id!!, - accountId = copyTrading.accountId, - side = "SELL", // 订单方向是SELL - price = sellPrice.toString(), - size = totalMatched.toString(), - errorMessage = errorMsg, - retryCount = 1 // 已重试一次 - ) + logger.error("创建卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, error=${exception?.message}") return } val realSellOrderId = createOrderResult.getOrNull() ?: return - // 12. 更新买入订单跟踪状态 + // 12. 下单时直接使用下单价格保存,等待定时任务更新实际成交价 + // priceUpdated 统一由定时任务更新,下单时统一设置为 false(非0x开头的除外) + val priceUpdated = !realSellOrderId.startsWith("0x", ignoreCase = true) + if (priceUpdated) { + logger.debug("卖出订单ID非0x开头,标记为已更新: orderId=$realSellOrderId") + } else { + logger.debug("卖出订单ID为0x开头,等待定时任务更新价格: orderId=$realSellOrderId") + } + + // 使用下单价格,等待定时任务更新实际成交价 + val actualSellPrice = sellPrice + + // 13. 更新买入订单跟踪状态 for (order in unmatchedOrders) { val detail = matchDetails.find { it.trackingId == order.id } if (detail != null) { @@ -907,8 +976,17 @@ open class CopyOrderTrackingService( } } - // 13. 创建卖出匹配记录(使用真实订单ID,使用outcomeIndex) - val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } + // 14. 重新计算盈亏(使用实际成交价) + val updatedMatchDetails = matchDetails.map { detail -> + val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity) + detail.copy( + sellPrice = actualSellPrice, + realizedPnl = updatedRealizedPnl + ) + } + + // 15. 创建卖出匹配记录(使用真实订单ID和实际成交价) + val totalRealizedPnl = updatedMatchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } val matchRecord = SellMatchRecord( copyTradingId = copyTrading.id, @@ -918,17 +996,20 @@ open class CopyOrderTrackingService( side = leaderSellTrade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据) outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段 totalMatchedQuantity = totalMatched, - sellPrice = sellPrice, - totalRealizedPnl = totalRealizedPnl + sellPrice = actualSellPrice, // 使用实际成交价(如果查询失败则为下单价格) + totalRealizedPnl = totalRealizedPnl, + priceUpdated = priceUpdated // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知) ) val savedRecord = sellMatchRecordRepository.save(matchRecord) - // 14. 保存匹配明细 - for (detail in matchDetails) { + // 16. 保存匹配明细(使用实际成交价) + for (detail in updatedMatchDetails) { val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) sellMatchDetailRepository.save(savedDetail) } + + logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}") } @@ -1122,98 +1203,6 @@ open class CopyOrderTrackingService( return "code=$code, errorBody=$errorBody" } - /** - * 记录失败交易到数据库 - * 注意:此方法在 @Transactional 方法中被调用,会自动继承事务 - */ - private suspend fun recordFailedTrade( - leaderId: Long, - trade: TradeResponse, - copyTradingId: Long, - accountId: Long, - side: String, - price: String, - size: String, - errorMessage: String, - retryCount: Int - ) { - try { - // 确保错误信息不超过数据库字段限制(TEXT类型通常支持65535字符) - val maxErrorMessageLength = 50000 // 保留一些余量 - val finalErrorMessage = if (errorMessage.length > maxErrorMessageLength) { - errorMessage.substring(0, maxErrorMessageLength) + "... (截断)" - } else { - errorMessage - } - - val failedTrade = FailedTrade( - leaderId = leaderId, - leaderTradeId = trade.id, - tradeType = trade.side.uppercase(), - copyTradingId = copyTradingId, - accountId = accountId, - marketId = trade.market, - side = side, - price = price, - size = size, - errorMessage = finalErrorMessage, - retryCount = retryCount, - failedAt = System.currentTimeMillis() - ) - failedTradeRepository.save(failedTrade) - - // 记录日志,确认已保存到数据库 - logger.info("失败交易已保存到数据库: leaderId=$leaderId, tradeId=${trade.id}, errorMessageLength=${finalErrorMessage.length}") - - // 标记为已处理(失败状态),避免重复处理 - // 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突 - try { - val processed = ProcessedTrade( - leaderId = leaderId, - leaderTradeId = trade.id, - tradeType = trade.side.uppercase(), - source = "polling", - status = "FAILED", - processedAt = System.currentTimeMillis() - ) - processedTradeRepository.save(processed) - } catch (e: Exception) { - // 检查是否是唯一键冲突异常 - if (isUniqueConstraintViolation(e)) { - // 唯一约束冲突,说明已经处理过了(可能是并发请求) - // 检查现有记录的状态 - val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) - if (existing != null) { - if (existing.status == "SUCCESS") { - logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}") - } else { - logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}") - } - } else { - // 如果查询不到,等待一下再查询(可能是事务隔离级别问题) - delay(100) - val existingAfterDelay = - processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) - if (existingAfterDelay != null) { - logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}") - } else { - logger.warn( - "保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", - e - ) - } - } - } else { - // 其他类型的异常,记录但不抛出(避免影响其他交易的处理) - logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e) - } - } - - logger.warn("已记录失败交易: leaderId=$leaderId, tradeId=${trade.id}, error=$errorMessage") - } catch (e: Exception) { - logger.error("记录失败交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) - } - } /** * 更新订单状态 @@ -1328,6 +1317,119 @@ open class CopyOrderTrackingService( FilterStatus.FAILED_ORDERBOOK_EMPTY -> "ORDERBOOK_EMPTY" 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" + } + } + + /** + * 验证订单ID格式 + * 订单ID必须以 0x 开头,且是有效的 16 进制字符串 + * + * @param orderId 订单ID + * @return 如果格式有效返回 true,否则返回 false + */ + private fun isValidOrderId(orderId: String): Boolean { + if (!orderId.startsWith("0x", ignoreCase = true)) { + return false + } + // 验证是否为有效的 16 进制字符串(去除 0x 前缀后) + val hexPart = orderId.substring(2) + if (hexPart.isEmpty()) { + return false + } + // 检查是否只包含 0-9, a-f, A-F + return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + } + + /** + * 获取订单的实际成交价 + * 通过查询订单详情和关联的交易记录,计算加权平均成交价 + * + * @param orderId 订单ID + * @param clobApi CLOB API 客户端(已认证) + * @param fallbackPrice 如果查询失败,使用此价格作为默认值 + * @return 实际成交价(加权平均),如果查询失败则返回 fallbackPrice + */ + suspend fun getActualExecutionPrice( + orderId: String, + clobApi: PolymarketClobApi, + fallbackPrice: BigDecimal + ): BigDecimal { + return try { + // 1. 查询订单详情 + val orderResponse = clobApi.getOrder(orderId) + if (!orderResponse.isSuccessful) { + val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情" + logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody") + return fallbackPrice + } + + val order = orderResponse.body() + if (order == null) { + // 响应体为空,可能是订单不存在或已过期 + logger.warn("查询订单详情失败: 响应体为空, orderId=$orderId, code=${orderResponse.code()}") + return fallbackPrice + } + + // 2. 如果订单未成交,使用下单价格 + if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) { + logger.debug("订单未成交,使用下单价格: orderId=$orderId, status=${order.status}") + return fallbackPrice + } + + // 3. 如果订单已成交,通过 associateTrades 获取交易记录 + val associateTrades = order.associateTrades + if (associateTrades.isNullOrEmpty()) { + logger.debug("订单无关联交易记录,使用下单价格: orderId=$orderId") + return fallbackPrice + } + + // 4. 查询所有关联的交易记录 + val trades = mutableListOf() + for (tradeId in associateTrades) { + try { + val tradesResponse = clobApi.getTrades(id = tradeId) + if (tradesResponse.isSuccessful && tradesResponse.body() != null) { + val tradesData = tradesResponse.body()!!.data + trades.addAll(tradesData) + } + } catch (e: Exception) { + logger.warn("查询交易记录失败: tradeId=$tradeId, error=${e.message}") + } + } + + if (trades.isEmpty()) { + logger.debug("未找到交易记录,使用下单价格: orderId=$orderId") + return fallbackPrice + } + + // 5. 计算加权平均成交价 + // 加权平均 = Σ(price * size) / Σ(size) + var totalAmount = BigDecimal.ZERO + var totalSize = BigDecimal.ZERO + + for (trade in trades) { + val tradePrice = trade.price.toSafeBigDecimal() + val tradeSize = trade.size.toSafeBigDecimal() + + if (tradeSize > BigDecimal.ZERO) { + totalAmount = totalAmount.add(tradePrice.multiply(tradeSize)) + totalSize = totalSize.add(tradeSize) + } + } + + if (totalSize > BigDecimal.ZERO) { + val weightedAveragePrice = totalAmount.divide(totalSize, 8, java.math.RoundingMode.HALF_UP) + logger.info("计算实际成交价成功: orderId=$orderId, 加权平均价=$weightedAveragePrice, 下单价格=$fallbackPrice, 交易笔数=${trades.size}") + return weightedAveragePrice + } else { + logger.warn("交易记录数量为0,使用下单价格: orderId=$orderId") + return fallbackPrice + } + } catch (e: Exception) { + logger.error("获取实际成交价异常: orderId=$orderId, error=${e.message}", e) + return fallbackPrice } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt index 9bffd2a..c875847 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt @@ -341,9 +341,9 @@ class CopyTradingStatisticsService( // 传递 outcomeIndex 参数,确保获取对应 outcome 的价格 val result = accountService.getMarketPrice(marketId, outcomeIndex) result.onSuccess { response -> - // 使用中间价,如果没有则使用最后价格 - val price = response.midpoint ?: response.lastPrice - if (price != null) { + // 使用当前价格 + val price = response.currentPrice + if (price.isNotBlank() && price != "0") { // 使用 "marketId:outcomeIndex" 作为 key val key = "$marketId:$outcomeIndex" prices[key] = price diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt new file mode 100644 index 0000000..ad6fa12 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt @@ -0,0 +1,887 @@ +package com.wrbug.polymarketbot.service.copytrading.statistics + +import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.entity.* +import com.wrbug.polymarketbot.repository.* +import com.wrbug.polymarketbot.service.system.TelegramNotificationService +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.CryptoUtils +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.multi +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +/** + * 订单状态更新服务 + * 定时轮询更新卖出订单的实际成交价,并更新买入订单的实际数据并发送通知 + */ +@Service +class OrderStatusUpdateService( + private val sellMatchRecordRepository: SellMatchRecordRepository, + private val sellMatchDetailRepository: SellMatchDetailRepository, + private val copyTradingRepository: CopyTradingRepository, + private val accountRepository: AccountRepository, + private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val leaderRepository: LeaderRepository, + private val retrofitFactory: RetrofitFactory, + private val cryptoUtils: CryptoUtils, + private val trackingService: CopyOrderTrackingService, + private val telegramNotificationService: TelegramNotificationService? +) { + + private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java) + + private val updateScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @EventListener(ApplicationReadyEvent::class) + fun onApplicationReady() { + logger.info("订单状态更新服务已启动,将每5秒轮询一次") + } + + /** + * 定时更新订单状态 + * 每5秒执行一次 + */ + @Scheduled(fixedDelay = 5000) + fun updateOrderStatus() { + updateScope.launch { + try { + // 1. 清理已删除账户的订单 + cleanupDeletedAccountOrders() + + // 2. 检查30秒前创建的订单,如果未成交则删除 + checkAndDeleteUnfilledOrders() + + // 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段) + updatePendingSellOrderPrices() + + // 4. 更新买入订单的实际数据并发送通知 + updatePendingBuyOrders() + } catch (e: Exception) { + logger.error("订单状态更新异常: ${e.message}", e) + } + } + } + + /** + * 验证订单ID格式 + * 订单ID必须以 0x 开头,且是有效的 16 进制字符串 + * + * @param orderId 订单ID + * @return 如果格式有效返回 true,否则返回 false + */ + private fun isValidOrderId(orderId: String): Boolean { + if (!orderId.startsWith("0x", ignoreCase = true)) { + return false + } + // 验证是否为有效的 16 进制字符串(去除 0x 前缀后) + val hexPart = orderId.substring(2) + if (hexPart.isEmpty()) { + return false + } + // 检查是否只包含 0-9, a-f, A-F + return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + } + + /** + * 清理已删除账户的订单 + */ + @Transactional + private suspend fun cleanupDeletedAccountOrders() { + try { + // 查询所有卖出记录 + val allRecords = sellMatchRecordRepository.findAll() + + // 查询所有有效的账户ID + val validAccountIds = accountRepository.findAll().mapNotNull { it.id }.toSet() + + // 查询所有有效的跟单关系 + val validCopyTradingIds = copyTradingRepository.findAll() + .filter { it.accountId in validAccountIds } + .mapNotNull { it.id } + .toSet() + + // 找出需要删除的记录(关联的跟单关系已不存在或账户已删除) + val recordsToDelete = allRecords.filter { record -> + val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null) + copyTrading == null || copyTrading.accountId !in validAccountIds + } + + if (recordsToDelete.isNotEmpty()) { + logger.info("清理已删除账户的订单: ${recordsToDelete.size} 条记录") + + // 删除匹配明细 + for (record in recordsToDelete) { + val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!) + sellMatchDetailRepository.deleteAll(details) + } + + // 删除卖出记录 + sellMatchRecordRepository.deleteAll(recordsToDelete) + + logger.info("已清理 ${recordsToDelete.size} 条已删除账户的订单记录") + } + } catch (e: Exception) { + logger.error("清理已删除账户订单异常: ${e.message}", e) + } + } + + /** + * 检查30秒前创建的订单,如果未成交则删除 + * 首次检测但加入缓存中30s后还没有成交,则删除 + */ + @Transactional + private suspend fun checkAndDeleteUnfilledOrders() { + try { + // 计算30秒前的时间戳 + val thirtySecondsAgo = System.currentTimeMillis() - 30000 + + // 查询30秒前创建的订单 + val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo) + + if (ordersToCheck.isEmpty()) { + return + } + + logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交") + + // 按账户分组,避免重复创建 API 客户端 + val ordersByAccount = ordersToCheck.groupBy { it.accountId } + + for ((accountId, orders) in ordersByAccount) { + try { + // 获取账户 + val account = accountRepository.findById(accountId).orElse(null) + if (account == null) { + logger.warn("账户不存在,跳过检查: accountId=$accountId") + continue + } + + // 检查账户是否配置了 API 凭证 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + logger.debug("账户未配置 API 凭证,跳过检查: accountId=${account.id}") + continue + } + + // 解密 API 凭证 + val apiSecret = try { + cryptoUtils.decrypt(account.apiSecret!!) + } catch (e: Exception) { + logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}") + continue + } + + val apiPassphrase = try { + cryptoUtils.decrypt(account.apiPassphrase!!) + } catch (e: Exception) { + logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}") + continue + } + + // 创建带认证的 CLOB API 客户端 + val clobApi = retrofitFactory.createClobApi( + account.apiKey!!, + apiSecret, + apiPassphrase, + account.walletAddress + ) + + // 检查每个订单 + for (order in orders) { + try { + // 查询订单详情 + val orderResponse = clobApi.getOrder(order.buyOrderId) + + // 先检查 HTTP 状态码,非 200 的都跳过 + if (orderResponse.code() != 200) { + // HTTP 非 200,记录日志并跳过,等待下次轮询 + // 不删除订单,因为可能是临时网络问题或 API 错误 + val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情" + logger.debug("订单查询失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody") + continue + } + + // HTTP 200,检查响应体 + // 响应体也可能返回字符串 "null",Gson 解析时会返回 null + val orderDetail = orderResponse.body() + if (orderDetail == null) { + // HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在 + // 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计 + val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty() + if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) { + logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}") + continue + } + + // 订单不存在且未部分卖出,删除本地订单 + logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}") + try { + copyOrderTrackingRepository.deleteById(order.id!!) + logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}") + } catch (e: Exception) { + logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e) + } + continue + } + + // 检查订单是否成交 + // 如果订单状态不是 FILLED 且已成交数量为0,说明未成交,删除 + val sizeMatched = orderDetail.sizeMatched?.toSafeBigDecimal() ?: BigDecimal.ZERO + if (orderDetail.status != "FILLED" && sizeMatched <= BigDecimal.ZERO) { + logger.info("订单30秒后仍未成交,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, status=${orderDetail.status}, sizeMatched=$sizeMatched") + try { + copyOrderTrackingRepository.deleteById(order.id!!) + logger.info("已删除未成交订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}") + } catch (e: Exception) { + logger.error("删除未成交订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e) + } + } else { + logger.debug("订单已成交或部分成交,保留: orderId=${order.buyOrderId}, status=${orderDetail.status}, sizeMatched=$sizeMatched") + } + } catch (e: Exception) { + logger.error("检查订单失败: orderId=${order.buyOrderId}, error=${e.message}", e) + } + } + } catch (e: Exception) { + logger.error("检查账户订单失败: accountId=$accountId, error=${e.message}", e) + } + } + } catch (e: Exception) { + logger.error("检查未成交订单异常: ${e.message}", e) + } + } + + /** + * 更新待更新的卖出订单价格 + * 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段) + */ + @Transactional + private suspend fun updatePendingSellOrderPrices() { + try { + // 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理) + val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse() + + if (pendingRecords.isEmpty()) { + return + } + + logger.debug("找到 ${pendingRecords.size} 条待更新价格的卖出订单") + + for (record in pendingRecords) { + try { + // 获取跟单关系 + val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null) + if (copyTrading == null) { + logger.warn("跟单关系不存在,跳过更新: copyTradingId=${record.copyTradingId}") + continue + } + + // 获取账户 + val account = accountRepository.findById(copyTrading.accountId).orElse(null) + if (account == null) { + logger.warn("账户不存在,跳过更新: accountId=${copyTrading.accountId}") + continue + } + + // 检查账户是否配置了 API 凭证 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}") + continue + } + + // 解密 API 凭证 + val apiSecret = try { + cryptoUtils.decrypt(account.apiSecret!!) + } catch (e: Exception) { + logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}") + continue + } + + val apiPassphrase = try { + cryptoUtils.decrypt(account.apiPassphrase!!) + } catch (e: Exception) { + logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}") + continue + } + + // 创建带认证的 CLOB API 客户端 + val clobApi = retrofitFactory.createClobApi( + account.apiKey!!, + apiSecret, + apiPassphrase, + account.walletAddress + ) + + // 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送) + if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) { + logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}") + + // 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则不发送通知 + val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) || + record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) || + record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true) + + if (!isAutoOrder) { + // 非自动订单,发送通知(使用临时数据) + sendSellOrderNotification( + record = record, + useTemporaryData = true, + account = account, + copyTrading = copyTrading, + clobApi = clobApi, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase + ) + } else { + logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}") + } + + // 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送) + val updatedRecord = SellMatchRecord( + id = record.id, + copyTradingId = record.copyTradingId, + sellOrderId = record.sellOrderId, + leaderSellTradeId = record.leaderSellTradeId, + marketId = record.marketId, + side = record.side, + outcomeIndex = record.outcomeIndex, + totalMatchedQuantity = record.totalMatchedQuantity, + sellPrice = record.sellPrice, + totalRealizedPnl = record.totalRealizedPnl, + priceUpdated = true, // 标记为已处理(价格已更新和通知已发送) + createdAt = record.createdAt + ) + sellMatchRecordRepository.save(updatedRecord) + continue + } + + // 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则跳过发送通知 + val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) || + record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) || + record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true) + + if (isAutoOrder) { + logger.debug("自动生成的订单,跳过发送通知并直接标记为已处理: orderId=${record.sellOrderId}") + // 直接标记为已处理,不发送通知 + val updatedRecord = SellMatchRecord( + id = record.id, + copyTradingId = record.copyTradingId, + sellOrderId = record.sellOrderId, + leaderSellTradeId = record.leaderSellTradeId, + marketId = record.marketId, + side = record.side, + outcomeIndex = record.outcomeIndex, + totalMatchedQuantity = record.totalMatchedQuantity, + sellPrice = record.sellPrice, + totalRealizedPnl = record.totalRealizedPnl, + priceUpdated = true, // 标记为已处理 + createdAt = record.createdAt + ) + sellMatchRecordRepository.save(updatedRecord) + continue + } + + // 查询订单详情,获取实际成交价 + val actualSellPrice = trackingService.getActualExecutionPrice( + orderId = record.sellOrderId, + clobApi = clobApi, + fallbackPrice = record.sellPrice + ) + + // 如果价格已更新(与当前价格不同),更新数据库 + if (actualSellPrice != record.sellPrice) { + // 重新计算盈亏 + val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!) + var totalRealizedPnl = BigDecimal.ZERO + + for (detail in details) { + val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity) + + // 更新明细的卖出价格和盈亏 + // 注意:SellMatchDetail 的字段都是 val,需要创建新对象 + val updatedDetail = SellMatchDetail( + id = detail.id, + matchRecordId = detail.matchRecordId, + trackingId = detail.trackingId, + buyOrderId = detail.buyOrderId, + matchedQuantity = detail.matchedQuantity, + buyPrice = detail.buyPrice, + sellPrice = actualSellPrice, // 更新卖出价格 + realizedPnl = updatedRealizedPnl, // 更新盈亏 + createdAt = detail.createdAt + ) + sellMatchDetailRepository.save(updatedDetail) + + totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl) + } + + // 发送通知(使用实际价格) + sendSellOrderNotification( + record = record, + actualPrice = actualSellPrice.toString(), + actualSize = record.totalMatchedQuantity.toString(), + account = account, + copyTrading = copyTrading, + clobApi = clobApi, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase + ) + + // 更新卖出记录 + // 注意:SellMatchRecord 的字段都是 val,需要创建新对象 + val updatedRecord = SellMatchRecord( + id = record.id, + copyTradingId = record.copyTradingId, + sellOrderId = record.sellOrderId, + leaderSellTradeId = record.leaderSellTradeId, + marketId = record.marketId, + side = record.side, + outcomeIndex = record.outcomeIndex, + totalMatchedQuantity = record.totalMatchedQuantity, + sellPrice = actualSellPrice, // 更新卖出价格 + totalRealizedPnl = totalRealizedPnl, // 更新总盈亏 + priceUpdated = true, // 标记为已处理(价格已更新和通知已发送) + createdAt = record.createdAt + ) + sellMatchRecordRepository.save(updatedRecord) + + logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice") + } else { + // 价格相同,但已经查询过,发送通知并标记为已处理 + sendSellOrderNotification( + record = record, + actualPrice = actualSellPrice.toString(), + actualSize = record.totalMatchedQuantity.toString(), + account = account, + copyTrading = copyTrading, + clobApi = clobApi, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase + ) + + val updatedRecord = SellMatchRecord( + id = record.id, + copyTradingId = record.copyTradingId, + sellOrderId = record.sellOrderId, + leaderSellTradeId = record.leaderSellTradeId, + marketId = record.marketId, + side = record.side, + outcomeIndex = record.outcomeIndex, + totalMatchedQuantity = record.totalMatchedQuantity, + sellPrice = record.sellPrice, + totalRealizedPnl = record.totalRealizedPnl, + priceUpdated = true, // 标记为已处理(价格已更新和通知已发送) + createdAt = record.createdAt + ) + sellMatchRecordRepository.save(updatedRecord) + logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice") + } + } catch (e: Exception) { + logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e) + // 继续处理下一条记录 + } + } + } catch (e: Exception) { + logger.error("更新待更新卖出订单价格异常: ${e.message}", e) + } + } + + /** + * 更新待发送通知的买入订单 + * 查询订单详情获取实际价格和数量,然后发送通知并更新数据库 + */ + @Transactional + private suspend fun updatePendingBuyOrders() { + try { + // 查询所有未发送通知的买入订单 + val pendingOrders = copyOrderTrackingRepository.findByNotificationSentFalse() + + if (pendingOrders.isEmpty()) { + return + } + + logger.debug("找到 ${pendingOrders.size} 条待发送通知的买入订单") + + for (order in pendingOrders) { + try { + // 验证 orderId 格式(必须以 0x 开头的 16 进制) + if (!isValidOrderId(order.buyOrderId)) { + logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}") + // 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知 + val updatedOrder = CopyOrderTracking( + id = order.id, + copyTradingId = order.copyTradingId, + accountId = order.accountId, + leaderId = order.leaderId, + marketId = order.marketId, + side = order.side, + outcomeIndex = order.outcomeIndex, + buyOrderId = order.buyOrderId, + leaderBuyTradeId = order.leaderBuyTradeId, + quantity = order.quantity, + price = order.price, + matchedQuantity = order.matchedQuantity, + remainingQuantity = order.remainingQuantity, + status = order.status, + notificationSent = true, // 标记为已发送通知 + createdAt = order.createdAt, + updatedAt = System.currentTimeMillis() + ) + copyOrderTrackingRepository.save(updatedOrder) + sendBuyOrderNotification(updatedOrder, useTemporaryData = true) + continue + } + + // 获取跟单关系 + val copyTrading = copyTradingRepository.findById(order.copyTradingId).orElse(null) + if (copyTrading == null) { + logger.warn("跟单关系不存在,跳过更新: copyTradingId=${order.copyTradingId}") + continue + } + + // 获取账户 + val account = accountRepository.findById(order.accountId).orElse(null) + if (account == null) { + logger.warn("账户不存在,跳过更新: accountId=${order.accountId}") + continue + } + + // 检查账户是否配置了 API 凭证 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}") + continue + } + + // 解密 API 凭证 + val apiSecret = try { + cryptoUtils.decrypt(account.apiSecret!!) + } catch (e: Exception) { + logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}") + continue + } + + val apiPassphrase = try { + cryptoUtils.decrypt(account.apiPassphrase!!) + } catch (e: Exception) { + logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}") + continue + } + + // 创建带认证的 CLOB API 客户端 + val clobApi = retrofitFactory.createClobApi( + account.apiKey!!, + apiSecret, + apiPassphrase, + account.walletAddress + ) + + // 查询订单详情 + val orderResponse = clobApi.getOrder(order.buyOrderId) + + // 先检查 HTTP 状态码,非 200 的都跳过 + if (orderResponse.code() != 200) { + val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情" + logger.debug("查询订单详情失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody") + continue + } + + // HTTP 200,检查响应体 + // 响应体也可能返回字符串 "null",Gson 解析时会返回 null + val orderDetail = orderResponse.body() + if (orderDetail == null) { + // HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在 + // 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计 + val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty() + if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) { + logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}") + continue + } + + // 订单不存在且未部分卖出,删除本地订单 + logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}") + try { + copyOrderTrackingRepository.deleteById(order.id!!) + logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}") + } catch (e: Exception) { + logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e) + } + continue + } + + // 获取实际价格和数量 + val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price + val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity + val actualOutcome = orderDetail.outcome + + // 更新订单数据(如果实际数据与临时数据不同) + val needUpdate = actualPrice != order.price || actualSize != order.quantity + + // 创建更新后的订单对象 + val updatedOrder = CopyOrderTracking( + id = order.id, + copyTradingId = order.copyTradingId, + accountId = order.accountId, + leaderId = order.leaderId, + marketId = order.marketId, + side = order.side, + outcomeIndex = order.outcomeIndex, + buyOrderId = order.buyOrderId, + leaderBuyTradeId = order.leaderBuyTradeId, + quantity = actualSize, // 使用实际数量 + price = actualPrice, // 使用实际价格 + matchedQuantity = order.matchedQuantity, + remainingQuantity = order.remainingQuantity, + status = order.status, + notificationSent = true, // 标记为已发送通知 + createdAt = order.createdAt, + updatedAt = System.currentTimeMillis() + ) + + // 保存更新后的订单 + copyOrderTrackingRepository.save(updatedOrder) + + if (needUpdate) { + logger.info("更新买入订单数据成功: orderId=${order.buyOrderId}, 原价格=${order.price}, 新价格=$actualPrice, 原数量=${order.quantity}, 新数量=$actualSize") + } else { + logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}") + } + + // 发送通知(使用实际数据) + sendBuyOrderNotification( + order = updatedOrder, + actualPrice = actualPrice.toString(), + actualSize = actualSize.toString(), + actualOutcome = actualOutcome, + account = account, + copyTrading = copyTrading, + clobApi = clobApi, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase + ) + } catch (e: Exception) { + logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e) + // 继续处理下一条记录 + } + } + } catch (e: Exception) { + logger.error("更新待发送通知买入订单异常: ${e.message}", e) + } + } + + /** + * 发送买入订单通知 + */ + private suspend fun sendBuyOrderNotification( + order: CopyOrderTracking, + useTemporaryData: Boolean = false, + actualPrice: String? = null, + actualSize: String? = null, + actualOutcome: String? = null, + account: Account? = null, + copyTrading: CopyTrading? = null, + clobApi: PolymarketClobApi? = null, + apiSecret: String? = null, + apiPassphrase: String? = null + ) { + if (telegramNotificationService == null) { + return + } + + try { + // 获取跟单关系和账户信息(如果未提供) + val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(order.copyTradingId).orElse(null) + if (finalCopyTrading == null) { + logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${order.copyTradingId}") + return + } + + val finalAccount = account ?: accountRepository.findById(order.accountId).orElse(null) + if (finalAccount == null) { + logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}") + return + } + + // 获取市场信息 + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: order.marketId + val marketSlug = marketInfo?.slug + + // 获取 Leader 和跟单配置信息 + val leader = leaderRepository.findById(order.leaderId).orElse(null) + val leaderName = leader?.leaderName + val configName = finalCopyTrading.configName + + // 获取当前语言设置 + val locale = try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + // 创建 CLOB API 客户端(如果未提供) + val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) { + retrofitFactory.createClobApi( + finalAccount.apiKey!!, + apiSecret, + apiPassphrase, + finalAccount.walletAddress + ) + } else { + null + } + + // 发送通知 + telegramNotificationService.sendOrderSuccessNotification( + orderId = order.buyOrderId, + marketTitle = marketTitle, + marketId = order.marketId, + marketSlug = marketSlug, + side = "BUY", + price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格 + size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量 + outcome = actualOutcome, // 使用实际 outcome + accountName = finalAccount.accountName, + walletAddress = finalAccount.walletAddress, + clobApi = finalClobApi, + apiKey = finalAccount.apiKey, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase, + walletAddressForApi = finalAccount.walletAddress, + locale = locale, + leaderName = leaderName, + configName = configName + ) + + logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}") + } catch (e: Exception) { + logger.warn("发送买入订单通知失败: orderId=${order.buyOrderId}, error=${e.message}", e) + } + } + + + /** + * 发送卖出订单通知 + */ + private suspend fun sendSellOrderNotification( + record: SellMatchRecord, + useTemporaryData: Boolean = false, + actualPrice: String? = null, + actualSize: String? = null, + actualOutcome: String? = null, + account: Account? = null, + copyTrading: CopyTrading? = null, + clobApi: PolymarketClobApi? = null, + apiSecret: String? = null, + apiPassphrase: String? = null + ) { + if (telegramNotificationService == null) { + return + } + + try { + // 获取跟单关系和账户信息(如果未提供) + val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(record.copyTradingId).orElse(null) + if (finalCopyTrading == null) { + logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${record.copyTradingId}") + return + } + + val finalAccount = account ?: accountRepository.findById(finalCopyTrading.accountId).orElse(null) + if (finalAccount == null) { + logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}") + return + } + + // 获取市场信息 + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: record.marketId + val marketSlug = marketInfo?.slug + + // 获取 Leader 和跟单配置信息 + val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null) + val leaderName = leader?.leaderName + val configName = finalCopyTrading.configName + + // 获取当前语言设置 + val locale = try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + // 创建 CLOB API 客户端(如果未提供) + val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) { + retrofitFactory.createClobApi( + finalAccount.apiKey!!, + apiSecret, + apiPassphrase, + finalAccount.walletAddress + ) + } else { + null + } + + // 发送通知 + telegramNotificationService.sendOrderSuccessNotification( + orderId = record.sellOrderId, + marketTitle = marketTitle, + marketId = record.marketId, + marketSlug = marketSlug, + side = "SELL", + price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格 + size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量 + outcome = actualOutcome, // 使用实际 outcome + accountName = finalAccount.accountName, + walletAddress = finalAccount.walletAddress, + clobApi = finalClobApi, + apiKey = finalAccount.apiKey, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase, + walletAddressForApi = finalAccount.walletAddress, + locale = locale, + leaderName = leaderName, + configName = configName + ) + + logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}") + } catch (e: Exception) { + logger.warn("发送卖出订单通知失败: orderId=${record.sellOrderId}, error=${e.message}", e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt index e619c80..094c0bf 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt @@ -28,12 +28,11 @@ class ApiHealthCheckService( private val dataApiBaseUrl: String, @Value("\${polymarket.gamma.base-url}") private val gammaBaseUrl: String, - @Value("\${polygon.rpc.url:}") - private val polygonRpcUrl: String, @Value("\${polymarket.rtds.ws-url}") private val polymarketWsUrl: String, @Value("\${polymarket.builder.relayer-url:}") - private val builderRelayerUrl: String + private val builderRelayerUrl: String, + private val rpcNodeService: RpcNodeService ) : ApplicationContextAware { private var applicationContext: ApplicationContext? = null @@ -186,19 +185,12 @@ class ApiHealthCheckService( /** * 检查 Polygon RPC + * 使用动态获取的可用节点(RpcNodeService 总是返回一个有效的 URL,包括默认节点) */ private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) { - if (polygonRpcUrl.isBlank()) { - return@withContext ApiHealthCheckDto( - name = "Polygon RPC", - url = "未配置", - status = "skipped", - message = "未配置 Polygon RPC URL" - ) - } - - val url = polygonRpcUrl - checkJsonRpcApi("Polygon RPC", url) + // 使用 RpcNodeService 获取可用节点(总是返回有效值,包括默认节点) + val rpcUrl = rpcNodeService.getHttpUrl() + checkJsonRpcApi("Polygon RPC", rpcUrl) } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt index 8c30a59..898a9cd 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt @@ -24,12 +24,11 @@ import java.math.BigInteger */ @Service class RelayClientService( - @Value("\${polygon.rpc.url:}") - private val polygonRpcUrl: String, @Value("\${polymarket.builder.relayer-url:}") private val builderRelayerUrl: String, private val retrofitFactory: RetrofitFactory, - private val systemConfigService: SystemConfigService + private val systemConfigService: SystemConfigService, + private val rpcNodeService: RpcNodeService ) { private val logger = LoggerFactory.getLogger(RelayClientService::class.java) @@ -43,12 +42,9 @@ class RelayClientService( // 空集合ID private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000" - private val polygonRpcApi: EthereumRpcApi? by lazy { - if (polygonRpcUrl.isBlank()) { - null - } else { - retrofitFactory.createEthereumRpcApi(polygonRpcUrl) - } + private val polygonRpcApi: EthereumRpcApi by lazy { + val rpcUrl = rpcNodeService.getHttpUrl() + retrofitFactory.createEthereumRpcApi(rpcUrl) } /** @@ -260,7 +256,7 @@ class RelayClientService( builderSecret: String, builderPassphrase: String ): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi val relayerApi = retrofitFactory.createBuilderRelayerApi( relayerUrl = builderRelayerUrl, apiKey = builderApiKey, @@ -337,15 +333,10 @@ class RelayClientService( // 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig) val packedSignature = splitAndPackSig(safeSignature) - // 调试日志 + // 调试日志(地址已遮蔽) logger.debug("=== Builder Relayer 签名调试 ===") - logger.debug("Safe Address: $proxyAddress") - logger.debug("From Address: $fromAddress") - logger.debug("To: ${safeTx.to}") - logger.debug("Data: $redeemCallData") - logger.debug("Nonce: $proxyNonce") - logger.debug("Packed Signature: $packedSignature") - logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)") + logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce") + logger.debug("Signature Length: ${packedSignature.length}") // 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts) // 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀 @@ -368,13 +359,7 @@ class RelayClientService( metadata = "Redeem positions via Builder Relayer" ) - logger.debug("Request Type: ${request.type}") - logger.debug("Request From: ${request.from}") - logger.debug("Request To: ${request.to}") - logger.debug("Request ProxyWallet: ${request.proxyWallet}") - logger.debug("Request Data Length: ${request.data.length}") - logger.debug("Request Signature Length: ${request.signature.length}") - logger.debug("Request Nonce: ${request.nonce}") + logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}") // 调用 Builder Relayer API(认证头通过拦截器添加) val response = relayerApi.submitTransaction(request) @@ -461,13 +446,7 @@ class RelayClientService( safeTx: SafeTransaction ): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法执行交易") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder Relayer(Gasless)")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 从私钥推导实际签名地址(交易真正的 from 地址) val cleanPrivateKey = privateKey.removePrefix("0x") @@ -665,7 +644,7 @@ class RelayClientService( } val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空")) - val nonce = EthereumUtils.decodeUint256(hexNonce) + val nonce = EthereumUtils.decodeUint256(hexNonce.asString) return Result.success(nonce) } @@ -689,7 +668,7 @@ class RelayClientService( } val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空")) - val nonce = EthereumUtils.decodeUint256(hexNonce) + val nonce = EthereumUtils.decodeUint256(hexNonce.asString) return Result.success(nonce) } @@ -713,7 +692,7 @@ class RelayClientService( } val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空")) - val gasPrice = EthereumUtils.decodeUint256(hexGasPrice) + val gasPrice = EthereumUtils.decodeUint256(hexGasPrice.asString) return Result.success(gasPrice) } @@ -784,7 +763,7 @@ class RelayClientService( } val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空")) - return Result.success(txHash) + return Result.success(txHash.asString) } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt new file mode 100644 index 0000000..eb5f197 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt @@ -0,0 +1,513 @@ +package com.wrbug.polymarketbot.service.system + +import com.wrbug.polymarketbot.api.EthereumRpcApi +import com.wrbug.polymarketbot.api.JsonRpcRequest +import com.wrbug.polymarketbot.entity.NodeHealthStatus +import com.wrbug.polymarketbot.entity.RpcNodeConfig +import com.wrbug.polymarketbot.entity.RpcProviderType +import com.wrbug.polymarketbot.repository.RpcNodeConfigRepository +import com.wrbug.polymarketbot.util.CryptoUtils +import com.wrbug.polymarketbot.util.RetrofitFactory +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * RPC 节点管理服务 + * 负责管理用户配置的 Polygon RPC 节点 + */ +@Service +class RpcNodeService( + private val rpcNodeConfigRepository: RpcNodeConfigRepository, + private val cryptoUtils: CryptoUtils, + private val retrofitFactory: RetrofitFactory +) { + + private val logger = LoggerFactory.getLogger(RpcNodeService::class.java) + + companion object { + // 默认公共节点 + private const val DEFAULT_RPC_URL = "https://polygon.publicnode.com" + private const val DEFAULT_WS_URL = "wss://polygon.publicnode.com" + + // 主流服务商 URL 模板 + private val PROVIDER_HTTP_TEMPLATES = mapOf( + RpcProviderType.ALCHEMY to "https://polygon-mainnet.g.alchemy.com/v2/{apiKey}", + RpcProviderType.INFURA to "https://polygon-mainnet.infura.io/v3/{apiKey}", + RpcProviderType.QUICKNODE to "https://your-endpoint.quiknode.pro/{apiKey}/", + RpcProviderType.CHAINSTACK to "https://polygon-mainnet.core.chainstack.com/{apiKey}", + RpcProviderType.GETBLOCK to "https://go.getblock.io/{apiKey}/" + ) + + private val PROVIDER_WS_TEMPLATES = mapOf( + RpcProviderType.ALCHEMY to "wss://polygon-mainnet.g.alchemy.com/v2/{apiKey}", + RpcProviderType.INFURA to "wss://polygon-mainnet.infura.io/ws/v3/{apiKey}", + RpcProviderType.QUICKNODE to "wss://your-endpoint.quiknode.pro/{apiKey}/", + RpcProviderType.CHAINSTACK to "wss://ws-polygon-mainnet.core.chainstack.com/{apiKey}", + RpcProviderType.GETBLOCK to "wss://go.getblock.io/{apiKey}/" + ) + } + + /** + * 获取所有节点配置(不包含默认节点) + * 默认节点作为兜底,不应该返回给前端 + */ + fun getAllNodes(): List { + val allNodes = rpcNodeConfigRepository.findAllByOrderByPriorityAsc() + + // 过滤掉默认节点,只返回用户配置的节点 + return allNodes.filterNot { isDefaultNode(it) } + } + + /** + * 获取所有节点配置(包含默认节点,用于内部使用) + * 只返回启用的节点,禁用的节点会被忽略 + * 默认节点始终排在最后 + */ + fun getAllNodesWithDefault(): List { + // 只查询启用的节点 + val allNodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc() + + // 分离默认节点和用户配置的节点 + val (defaultNodes, userNodes) = allNodes.partition { isDefaultNode(it) } + + // 返回用户配置的节点,默认节点排在最后(如果存在) + return userNodes + defaultNodes + } + + /** + * 判断是否是默认节点 + */ + private fun isDefaultNode(node: RpcNodeConfig): Boolean { + return node.httpUrl == DEFAULT_RPC_URL || + node.httpUrl == DEFAULT_RPC_URL.removeSuffix("/") || + (node.providerType == RpcProviderType.PUBLIC.name && + (node.httpUrl.contains("polygon.publicnode.com") || + node.httpUrl.contains("publicnode.com"))) + } + + /** + * 获取第一个可用的节点 + * 按优先级顺序遍历所有启用的节点,找到第一个真正可用的节点 + * 如果所有节点都不可用,返回默认节点 + * @return 可用节点的配置,如果没有可用节点则返回失败 + */ + fun getAvailableNode(): Result { + return try { + val nodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc() + .filterNot { isDefaultNode(it) } // 排除默认节点 + + if (nodes.isEmpty()) { + logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL") + return Result.failure(IllegalStateException("没有配置任何 RPC 节点")) + } + + // 优先使用最近检查状态为 HEALTHY 的节点 + val healthyNodes = nodes.filter { + it.lastCheckStatus == NodeHealthStatus.HEALTHY.name + } + + // 先尝试使用健康的节点(按优先级排序) + for (node in healthyNodes) { + try { + // 快速验证节点是否仍然可用(使用较短的超时时间) + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull() + if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) { + logger.debug("使用健康的 RPC 节点: ${node.name} (${node.httpUrl})") + return Result.success(node) + } + } catch (e: Exception) { + logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}") + // 继续尝试下一个节点 + } + } + + // 如果没有健康的节点,尝试验证所有节点(按优先级) + for (node in nodes) { + try { + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull() + if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) { + logger.info("找到可用的 RPC 节点: ${node.name} (${node.httpUrl})") + return Result.success(node) + } + } catch (e: Exception) { + logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}") + // 继续尝试下一个节点 + } + } + + // 所有节点都不可用,返回失败 + logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL") + Result.failure(IllegalStateException("所有 RPC 节点都不可用")) + } catch (e: Exception) { + logger.error("获取可用节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取节点的 HTTP URL (如果没有配置,使用默认节点) + */ + fun getHttpUrl(): String { + val nodeResult = getAvailableNode() + return if (nodeResult.isSuccess) { + nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL + } else { + logger.warn("没有可用的用户配置节点,使用默认节点") + DEFAULT_RPC_URL + } + } + + /** + * 获取节点的 WebSocket URL (如果没有配置,使用默认节点) + */ + fun getWsUrl(): String { + val nodeResult = getAvailableNode() + return if (nodeResult.isSuccess) { + nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL + } else { + logger.warn("没有可用的用户配置节点,使用默认 WS 节点") + DEFAULT_WS_URL + } + } + + /** + * 添加节点 + */ + @Transactional + fun addNode(request: AddRpcNodeRequest): Result { + return try { + // 1. 验证请求 + val providerType = try { + RpcProviderType.valueOf(request.providerType.uppercase()) + } catch (e: IllegalArgumentException) { + return Result.failure(IllegalArgumentException("不支持的服务商类型: ${request.providerType}")) + } + + // 2. 构建 HTTP 和 WS URL + val (httpUrl, wsUrl) = if (providerType == RpcProviderType.CUSTOM) { + // 自定义节点,使用用户提供的 URL + if (request.httpUrl.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("自定义节点必须提供 HTTP URL")) + } + Pair(request.httpUrl, request.wsUrl) + } else { + // 主流服务商,使用模板生成 URL + if (request.apiKey.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("${request.providerType} 节点必须提供 API Key")) + } + val httpTemplate = PROVIDER_HTTP_TEMPLATES[providerType] + ?: return Result.failure(IllegalArgumentException("未找到 ${request.providerType} 的 HTTP URL 模板")) + val wsTemplate = PROVIDER_WS_TEMPLATES[providerType] + Pair( + httpTemplate.replace("{apiKey}", request.apiKey), + wsTemplate?.replace("{apiKey}", request.apiKey) + ) + } + + // 3. 校验节点可用性 + val validationResult = validateNode(httpUrl, wsUrl) + if (validationResult.isFailure) { + return Result.failure(validationResult.exceptionOrNull() ?: Exception("节点验证失败")) + } + + val checkResult = validationResult.getOrNull()!! + + // 检查节点是否健康,如果不健康则不允许添加 + if (checkResult.status != NodeHealthStatus.HEALTHY) { + return Result.failure(IllegalArgumentException("节点不可用: ${checkResult.message}")) + } + + // 4. 加密 API Key (如果有) + val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) } + + // 5. 获取当前最大优先级 + val maxPriority = rpcNodeConfigRepository.findAllByOrderByPriorityAsc() + .maxOfOrNull { it.priority } ?: 0 + + // 6. 创建节点配置 + val node = RpcNodeConfig( + providerType = providerType.name, + name = request.name, + httpUrl = httpUrl, + wsUrl = wsUrl, + apiKey = encryptedApiKey, + enabled = true, + priority = maxPriority + 1, // 新节点放到最后 + lastCheckTime = checkResult.checkTime, + lastCheckStatus = checkResult.status.name, + responseTimeMs = checkResult.responseTimeMs + ) + + val savedNode = rpcNodeConfigRepository.save(node) + logger.info("成功添加 RPC 节点: ${savedNode.name} (${savedNode.httpUrl})") + Result.success(savedNode) + } catch (e: Exception) { + logger.error("添加节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 更新节点 + * 默认节点不允许更新(作为兜底,不应该返回给前端) + */ + @Transactional + fun updateNode(request: UpdateRpcNodeRequest): Result { + return try { + val node = rpcNodeConfigRepository.findById(request.id).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: ${request.id}")) + + // 如果是默认节点,不允许更新 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许更新")) + } + + // 更新字段 + val updatedNode = node.copy( + name = request.name ?: node.name, + enabled = request.enabled ?: node.enabled, + priority = request.priority ?: node.priority, + updatedAt = System.currentTimeMillis() + ) + + val savedNode = rpcNodeConfigRepository.save(updatedNode) + logger.info("成功更新 RPC 节点: ${savedNode.name}") + Result.success(savedNode) + } catch (e: Exception) { + logger.error("更新节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 删除节点 + * 默认节点不允许删除(作为兜底,不应该返回给前端) + */ + @Transactional + fun deleteNode(id: Long): Result { + return try { + val node = rpcNodeConfigRepository.findById(id).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: $id")) + + // 如果是默认节点,不允许删除 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许删除")) + } + + rpcNodeConfigRepository.delete(node) + logger.info("成功删除 RPC 节点: ${node.name}") + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 更新节点优先级 + * 默认节点不允许更新优先级(作为兜底,始终排在最后) + */ + @Transactional + fun updatePriority(id: Long, priority: Int): Result { + return try { + val node = rpcNodeConfigRepository.findById(id).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: $id")) + + // 如果是默认节点,不允许更新优先级 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许更新优先级")) + } + + val updatedNode = node.copy( + priority = priority, + updatedAt = System.currentTimeMillis() + ) + + rpcNodeConfigRepository.save(updatedNode) + logger.info("成功更新节点优先级: ${node.name} -> $priority") + Result.success(Unit) + } catch (e: Exception) { + logger.error("更新节点优先级失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 检查单个节点健康状态 + * 默认节点不应该被检查(作为兜底,不应该返回给前端) + */ + @Transactional + fun checkNodeHealth(nodeId: Long): Result { + return try { + val node = rpcNodeConfigRepository.findById(nodeId).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: $nodeId")) + + // 如果是默认节点,不允许检查 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许检查")) + } + + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrThrow() + + // 更新节点健康状态 + val updatedNode = node.copy( + lastCheckTime = checkResult.checkTime, + lastCheckStatus = checkResult.status.name, + responseTimeMs = checkResult.responseTimeMs, + updatedAt = System.currentTimeMillis() + ) + + rpcNodeConfigRepository.save(updatedNode) + logger.info("检查节点健康状态: ${node.name} -> ${checkResult.status}") + Result.success(checkResult) + } catch (e: Exception) { + logger.error("检查节点健康状态失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 批量检查所有节点健康状态(不包含默认节点和禁用的节点) + * 默认节点作为兜底,不应该返回给前端 + * 禁用的节点不应该被检查 + */ + @Transactional + fun checkAllNodesHealth(): Result> { + return try { + // 只查询启用的节点,过滤掉默认节点和禁用的节点 + val allNodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc() + // 过滤掉默认节点,只检查用户配置的启用节点 + val nodes = allNodes.filterNot { isDefaultNode(it) } + val results = mutableMapOf() + + for (node in nodes) { + try { + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull() + if (checkResult != null) { + results[node.id!!] = checkResult + + // 更新节点状态 + val updatedNode = node.copy( + lastCheckTime = checkResult.checkTime, + lastCheckStatus = checkResult.status.name, + responseTimeMs = checkResult.responseTimeMs, + updatedAt = System.currentTimeMillis() + ) + rpcNodeConfigRepository.save(updatedNode) + } + } catch (e: Exception) { + logger.error("检查节点 ${node.name} 失败: ${e.message}", e) + } + } + + Result.success(results) + } catch (e: Exception) { + logger.error("批量检查节点健康状态失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 校验节点可用性 + * 调用 eth_blockNumber 验证节点是否可用 + */ + private fun validateNode(httpUrl: String, wsUrl: String?): Result { + return try { + logger.debug("开始验证节点: $httpUrl") + + // 创建临时 RPC API + val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl) + + // 调用 eth_blockNumber + val startTime = System.currentTimeMillis() + val rpcRequest = JsonRpcRequest( + method = "eth_blockNumber", + params = emptyList() + ) + + val response = kotlinx.coroutines.runBlocking { rpcApi.call(rpcRequest) } + val responseTime = (System.currentTimeMillis() - startTime).toInt() + + if (!response.isSuccessful || response.body() == null) { + logger.warn("节点验证失败: HTTP ${response.code()}") + return Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "HTTP 请求失败: ${response.code()}", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime + )) + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + logger.warn("节点验证失败: RPC 错误 ${rpcResponse.error.message}") + return Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "RPC 错误: ${rpcResponse.error.message}", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime + )) + } + + val blockNumber = rpcResponse.result?.asString + if (blockNumber.isNullOrBlank()) { + return Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "区块号为空", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime + )) + } + + logger.info("节点验证成功: $httpUrl, 区块号: $blockNumber, 响应时间: ${responseTime}ms") + Result.success(NodeCheckResult( + status = NodeHealthStatus.HEALTHY, + message = "节点可用, 当前区块: $blockNumber", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime, + blockNumber = blockNumber + )) + } catch (e: Exception) { + logger.error("验证节点失败: ${e.message}", e) + Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "验证失败: ${e.message}", + checkTime = System.currentTimeMillis(), + responseTimeMs = null + )) + } + } +} + +/** + * 添加节点请求 + */ +data class AddRpcNodeRequest( + val providerType: String, // ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC + val name: String, + val apiKey: String? = null, // 主流服务商需要 + val httpUrl: String? = null, // CUSTOM 需要 + val wsUrl: String? = null +) + +/** + * 更新节点请求 + */ +data class UpdateRpcNodeRequest( + val id: Long, + val name: String? = null, + val enabled: Boolean? = null, + val priority: Int? = null +) + +/** + * 节点检查结果 + */ +data class NodeCheckResult( + val status: NodeHealthStatus, + val message: String, + val checkTime: Long, + val responseTimeMs: Int?, + val blockNumber: String? = null +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt index db20e6c..819c1e9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt @@ -25,6 +25,19 @@ class SystemConfigService( const val CONFIG_KEY_BUILDER_SECRET = "builder.secret" const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase" const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem" + + /** + * 遮蔽敏感信息,仅显示前4位和后4位 + * 例如:abcd1234...wxyz5678 + */ + fun maskSensitiveValue(value: String?): String? { + if (value == null) return null + return when { + value.length <= 8 -> "****" // 太短则完全遮蔽 + value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}" + else -> "${value.take(4)}...${value.takeLast(4)}" + } + } } /** @@ -36,26 +49,26 @@ class SystemConfigService( val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE) val autoRedeem = isAutoRedeemEnabled() - // 获取完整的 API Key(用于前端展示) - val builderApiKeyDisplay = builderApiKey?.let { + // 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置) + val builderApiKeyDisplay = builderApiKey?.let { try { - cryptoUtils.decrypt(it) + maskSensitiveValue(cryptoUtils.decrypt(it)) } catch (e: Exception) { null } } - + val builderSecretDisplay = builderSecret?.let { try { - cryptoUtils.decrypt(it) + maskSensitiveValue(cryptoUtils.decrypt(it)) } catch (e: Exception) { null } } - + val builderPassphraseDisplay = builderPassphrase?.let { try { - cryptoUtils.decrypt(it) + maskSensitiveValue(cryptoUtils.decrypt(it)) } catch (e: Exception) { null } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index 83655c1..24860ae 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -65,6 +65,9 @@ class TelegramNotificationService( marketId: String? = null, marketSlug: String? = null, side: String, + price: String? = null, // 订单价格(可选,如果提供则直接使用) + size: String? = null, // 订单数量(可选,如果提供则直接使用) + outcome: String? = null, // 市场方向(可选,如果提供则直接使用) accountName: String? = null, walletAddress: String? = null, clobApi: PolymarketClobApi? = null, @@ -84,35 +87,49 @@ class TelegramNotificationService( java.util.Locale("zh", "CN") // 默认简体中文 } - // 尝试从订单详情获取实际价格和数量 - var actualPrice: String? = null - var actualSize: String? = null + // 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取 + var actualPrice: String? = price + var actualSize: String? = size var actualSide: String = side - var actualOutcome: String? = null // 市场方向(outcome) + var actualOutcome: String? = outcome - if (orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) { + // 如果价格或数量未提供,尝试从订单详情获取 + if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) { try { val orderResponse = clobApi.getOrder(orderId) - if (orderResponse.isSuccessful && orderResponse.body() != null) { - val order = orderResponse.body()!! - actualPrice = order.price - actualSize = order.originalSize // 使用 originalSize 作为订单数量 - actualSide = order.side // 使用订单详情中的 side - actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向) + if (orderResponse.isSuccessful) { + val order = orderResponse.body() + if (order != null) { + if (actualPrice == null) { + actualPrice = order.price + } + if (actualSize == null) { + actualSize = order.originalSize // 使用 originalSize 作为订单数量 + } + actualSide = order.side // 使用订单详情中的 side + if (actualOutcome == null) { + actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向) + } + } else { + logger.debug("查询订单详情失败: 响应体为空, orderId=$orderId") + } + } else { + val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情" + logger.debug("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody") } } catch (e: Exception) { - logger.warn("查询订单详情失败,使用默认值: ${e.message}", e) + logger.warn("查询订单详情失败: orderId=$orderId, ${e.message}", e) } } - // 如果没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留) - val price = actualPrice ?: "0" - val size = actualSize ?: "0" + // 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留) + val finalPrice = actualPrice ?: "0" + val finalSize = actualSize ?: "0" // 计算订单金额 = price × size(USDC) val amount = try { - val priceDecimal = price.toSafeBigDecimal() - val sizeDecimal = size.toSafeBigDecimal() + val priceDecimal = finalPrice.toSafeBigDecimal() + val sizeDecimal = finalSize.toSafeBigDecimal() priceDecimal.multiply(sizeDecimal).toString() } catch (e: Exception) { logger.warn("计算订单金额失败: ${e.message}", e) @@ -126,8 +143,8 @@ class TelegramNotificationService( marketSlug = marketSlug, side = actualSide, outcome = actualOutcome, - price = price, - size = size, + price = finalPrice, + size = finalSize, amount = amount, accountName = accountName, walletAddress = walletAddress, @@ -296,18 +313,8 @@ class TelegramNotificationService( else -> filterType } - // 优先使用账户名称,如果没有账户名称才显示钱包地址 - val accountInfo: String = when { - !accountName.isNullOrBlank() -> { - accountName!! - } - !walletAddress.isNullOrBlank() -> { - maskAddress(walletAddress!!) - } - else -> { - unknownAccount - } - } + // 构建账户信息(格式:账户名(钱包地址)) + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) val time = DateUtils.formatDateTime() @@ -359,13 +366,17 @@ class TelegramNotificationService( "" } + // 格式化价格和数量 + val priceDisplay = formatPrice(price) + val sizeDisplay = formatQuantity(size) + return """🚫 $orderFiltered 📊 $orderInfo: • $marketLabel: $marketDisplay$outcomeDisplay • $sideLabel: $sideDisplay -• $priceLabel: $price -• $quantityLabel: $size shares +• $priceLabel: $priceDisplay +• $quantityLabel: $sizeDisplay shares • $amountLabel: $amountDisplay USDC • $accountLabel: $escapedAccountInfo @@ -570,6 +581,68 @@ class TelegramNotificationService( } } + /** + * 格式化价格显示(保留最多4位小数,截断不四舍五入) + */ + private fun formatPrice(price: String): String { + return try { + val priceDecimal = price.toSafeBigDecimal() + val formatted = if (priceDecimal.scale() > 4) { + priceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + priceDecimal.stripTrailingZeros() + } + formatted.toPlainString() + } catch (e: Exception) { + price + } + } + + /** + * 格式化数量显示(保留最多2位小数,截断不四舍五入) + */ + private fun formatQuantity(quantity: String): String { + return try { + val quantityDecimal = quantity.toSafeBigDecimal() + val formatted = if (quantityDecimal.scale() > 2) { + quantityDecimal.setScale(2, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + quantityDecimal.stripTrailingZeros() + } + formatted.toPlainString() + } catch (e: Exception) { + quantity + } + } + + /** + * 构建账户信息显示(格式:账户名(钱包地址)) + */ + private fun buildAccountInfo( + accountName: String?, + walletAddress: String?, + unknownAccount: String + ): String { + return when { + !accountName.isNullOrBlank() && !walletAddress.isNullOrBlank() -> { + // 有账户名和钱包地址:账户名(钱包地址) + "${accountName}(${maskAddress(walletAddress)})" + } + !accountName.isNullOrBlank() -> { + // 只有账户名 + accountName + } + !walletAddress.isNullOrBlank() -> { + // 只有钱包地址 + maskAddress(walletAddress) + } + else -> { + // 都没有 + unknownAccount + } + } + } + /** * 构建订单成功消息 */ @@ -613,18 +686,8 @@ class TelegramNotificationService( else -> side } - // 优先使用账户名称,如果没有账户名称才显示钱包地址 - val accountInfo: String = when { - !accountName.isNullOrBlank() -> { - accountName!! - } - !walletAddress.isNullOrBlank() -> { - maskAddress(walletAddress!!) - } - else -> { - unknownAccount - } - } + // 构建账户信息(格式:账户名(钱包地址)) + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) // 构建跟单信息(如果有) val copyTradingInfo = mutableListOf() @@ -694,14 +757,18 @@ class TelegramNotificationService( "" } + // 格式化价格和数量 + val priceDisplay = formatPrice(price) + val sizeDisplay = formatQuantity(size) + return """✅ $orderCreatedSuccess 📊 $orderInfo: • $orderIdLabel: ${orderId ?: unknown} • $marketLabel: $marketDisplay$outcomeDisplay • $sideLabel: $sideDisplay -• $priceLabel: $price -• $quantityLabel: $size shares +• $priceLabel: $priceDisplay +• $quantityLabel: $sizeDisplay shares • $amountLabel: $amountDisplay USDC • $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo @@ -748,18 +815,8 @@ class TelegramNotificationService( else -> side } - // 优先使用账户名称,如果没有账户名称才显示钱包地址 - val accountInfo: String = when { - !accountName.isNullOrBlank() -> { - accountName!! - } - !walletAddress.isNullOrBlank() -> { - maskAddress(walletAddress!!) - } - else -> { - unknownAccount - } - } + // 构建账户信息(格式:账户名(钱包地址)) + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) val time = DateUtils.formatDateTime() @@ -818,13 +875,17 @@ class TelegramNotificationService( "" } + // 格式化价格和数量 + val priceDisplay = formatPrice(price) + val sizeDisplay = formatQuantity(size) + return """❌ $orderCreatedFailed 📊 $orderInfo: • $marketLabel: $marketDisplay$outcomeDisplay • $sideLabel: $sideDisplay -• $priceLabel: $price -• $quantityLabel: $size shares +• $priceLabel: $priceDisplay +• $quantityLabel: $sizeDisplay shares • $amountLabel: $amountDisplay USDC • $accountLabel: $escapedAccountInfo @@ -889,18 +950,8 @@ class TelegramNotificationService( val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" - // 优先使用账户名称,如果没有账户名称才显示钱包地址 - val accountInfo: String = when { - !accountName.isNullOrBlank() -> { - accountName!! - } - !walletAddress.isNullOrBlank() -> { - maskAddress(walletAddress!!) - } - else -> { - unknownAccount - } - } + // 构建账户信息(格式:账户名(钱包地址)) + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) val time = DateUtils.formatDateTime() @@ -923,12 +974,7 @@ class TelegramNotificationService( // 构建仓位列表 val positionsText = positions.joinToString("\n") { position -> - val quantityDisplay = try { - val quantityDecimal = position.quantity.toSafeBigDecimal() - quantityDecimal.stripTrailingZeros().toPlainString() - } catch (e: Exception) { - position.quantity - } + val quantityDisplay = formatQuantity(position.quantity) val valueDisplay = try { val valueDecimal = position.value.toSafeBigDecimal() val formatted = if (valueDecimal.scale() > 4) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt index 51f3d35..f4fc82d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt @@ -19,7 +19,7 @@ object EthereumUtils { * @return 函数选择器,例如 "0x12345678" */ fun getFunctionSelector(functionSignature: String): String { - val hash = keccak256(functionSignature.toByteArray()) + val hash = keccak256Hex(functionSignature.toByteArray()) return "0x" + hash.substring(0, 8) } @@ -77,16 +77,109 @@ object EthereumUtils { return BigInteger(cleanHex, 16) } + /** + * 从 ABI 编码的响应中解析 uint256 数组 + * ABI 编码格式: + * - offset (32 bytes): 数组数据的位置偏移量 + * - length (32 bytes): 数组长度 + * - data: 每个元素 32 字节 + * @param hexResult 十六进制结果(完整的 ABI 编码响应) + * @param offset 数组数据的偏移位置(字节数,从 offset 位置开始读取) + * @return BigInteger 数组 + */ + fun decodeUint256Array(hexResult: String, offset: Int = 0): List { + val cleanHex = hexResult.removePrefix("0x") + if (cleanHex.length < (offset + 1) * 64) { + return emptyList() + } + + // 从 offset 位置开始读取 + val startPos = offset * 64 // 每个 uint256 是 64 个十六进制字符 + val lengthHex = cleanHex.substring(startPos, startPos + 64) + val length = BigInteger(lengthHex, 16).toInt() + + if (length <= 0 || length > 100) { // 防止异常数据 + return emptyList() + } + + val result = mutableListOf() + for (i in 0 until length) { + val elementStart = startPos + 64 + (i * 64) // 跳过长度字段 + if (elementStart + 64 > cleanHex.length) { + break + } + val elementHex = cleanHex.substring(elementStart, elementStart + 64) + result.add(BigInteger(elementHex, 16)) + } + + return result + } + + /** + * 从 ABI 编码的元组响应中解析数据 + * 用于解析 getCondition 返回的 (uint256 payoutDenominator, uint256[] payouts) + * @param hexResult 十六进制结果 + * @return Pair + */ + fun decodeConditionResult(hexResult: String): Pair> { + val cleanHex = hexResult.removePrefix("0x") + + // 第一个 32 字节:payoutDenominator + val payoutDenominatorHex = cleanHex.substring(0, 64) + val payoutDenominator = BigInteger(payoutDenominatorHex, 16) + + // 第二个 32 字节:payouts 数组的偏移量(通常是 0x40 = 64 字节) + val offsetHex = cleanHex.substring(64, 128) + val offset = BigInteger(offsetHex, 16).toInt() / 32 // 转换为 32 字节单位 + + // 从 offset 位置解析数组 + val payouts = decodeUint256Array(hexResult, offset) + + return Pair(payoutDenominator, payouts) + } + + /** + * 将十六进制字符串转换为字节数组 + * @param hex 十六进制字符串(带或不带 0x 前缀) + * @return 字节数组 + */ + fun hexToBytes(hex: String): ByteArray { + val cleanHex = hex.removePrefix("0x") + return ByteArray(cleanHex.length / 2) { i -> + cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } + + /** + * 将字节数组转换为十六进制字符串 + * @param bytes 字节数组 + * @return 十六进制字符串(带 0x 前缀) + */ + fun bytesToHex(bytes: ByteArray): String { + return "0x" + bytes.joinToString("") { "%02x".format(it) } + } + /** * 计算 Keccak-256 哈希(Ethereum 标准) * 使用 BouncyCastle 库实现真正的 Keccak-256 + * @param data 输入数据 + * @return 32 字节的哈希值 */ - private fun keccak256(data: ByteArray): String { + fun keccak256(data: ByteArray): ByteArray { val digest = KeccakDigest(256) digest.update(data, 0, data.size) val hash = ByteArray(digest.digestSize) digest.doFinal(hash, 0) - return hash.joinToString("") { "%02x".format(it) } + return hash + } + + /** + * 计算 Keccak-256 哈希并返回十六进制字符串 + * @param data 输入数据 + * @return 十六进制哈希字符串 + */ + fun keccak256Hex(data: ByteArray): String { + return keccak256(data).joinToString("") { "%02x".format(it) } } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt index 36003f0..b5f344c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt @@ -2,14 +2,16 @@ package com.wrbug.polymarketbot.util import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import org.springframework.stereotype.Component /** * JSON 工具类 * 用于解析 JSON 字符串 */ -object JsonUtils { - - private val gson = Gson() +@Component +class JsonUtils( + private val gson: Gson +) { /** * 解析 JSON 字符串数组 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 1a58237..977e943 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -1,7 +1,6 @@ package com.wrbug.polymarketbot.util import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.wrbug.polymarketbot.api.BuilderRelayerApi import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.GitHubApi @@ -9,30 +8,132 @@ import com.wrbug.polymarketbot.api.NbaStatsApi import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PolymarketGammaApi +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer +import java.util.concurrent.TimeUnit import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import jakarta.annotation.PreDestroy /** * Retrofit 客户端工厂 * 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端 + * + * 注意:为了避免内存泄漏,本类会缓存和复用客户端实例 */ @Component class RetrofitFactory( @Value("\${polymarket.clob.base-url}") private val clobBaseUrl: String, @Value("\${polymarket.gamma.base-url}") - private val gammaBaseUrl: String + private val gammaBaseUrl: String, + private val gson: Gson ) { + private val logger = LoggerFactory.getLogger(RetrofitFactory::class.java) + + // 共享的 OkHttpClient(用于不需要认证的 API) + private val sharedOkHttpClient: OkHttpClient by lazy { + createClient().build() + } + + // 共享的 OkHttpClient(用于需要跟随重定向的 API) + private val sharedOkHttpClientWithRedirect: OkHttpClient by lazy { + createClient() + .followRedirects(true) + .followSslRedirects(true) + .build() + } + + // 缓存 Gamma API 客户端(单例) + private val gammaApi: PolymarketGammaApi by lazy { + val baseUrl = if (gammaBaseUrl.endsWith("/")) { + gammaBaseUrl.dropLast(1) + } else { + gammaBaseUrl + } + + Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(sharedOkHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(PolymarketGammaApi::class.java) + } + + // 缓存 Data API 客户端(单例) + private val dataApi: PolymarketDataApi by lazy { + val baseUrl = "https://data-api.polymarket.com" + + Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(sharedOkHttpClientWithRedirect) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(PolymarketDataApi::class.java) + } + + // 缓存 GitHub API 客户端(单例) + private val githubApi: GitHubApi by lazy { + val baseUrl = "https://api.github.com" + + // 添加拦截器,设置 Accept 头以获取 reactions 数据 + val githubInterceptor = object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("Accept", "application/vnd.github+json") + .build() + return chain.proceed(request) + } + } + + val okHttpClient = createClient() + .addInterceptor(githubInterceptor) + .build() + + Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(GitHubApi::class.java) + } + + // 缓存不带认证的 CLOB API 客户端(单例) + private val clobApiWithoutAuth: PolymarketClobApi by lazy { + Retrofit.Builder() + .baseUrl(clobBaseUrl) + .client(sharedOkHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(PolymarketClobApi::class.java) + } + + // 缓存带认证的 CLOB API 客户端:walletAddress -> PolymarketClobApi + // 注意:每个账户使用不同的 API Key,需要不同的客户端 + private val clobApiCache = ConcurrentHashMap() + + // 缓存 RPC API 客户端:rpcUrl -> EthereumRpcApi + private val rpcApiCache = ConcurrentHashMap() + + // 缓存 Builder Relayer API 客户端:relayerUrl -> BuilderRelayerApi + private val builderRelayerApiCache = ConcurrentHashMap() + /** * 创建带认证的 Polymarket CLOB API 客户端 + * 按钱包地址缓存,避免重复创建 * @param apiKey API Key * @param apiSecret API Secret * @param apiPassphrase API Passphrase @@ -45,129 +146,169 @@ class RetrofitFactory( apiPassphrase: String, walletAddress: String ): PolymarketClobApi { - val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress) - - // 添加响应日志拦截器,用于调试 JSON 解析错误 - val responseLoggingInterceptor = ResponseLoggingInterceptor() - - val okHttpClient = createClient() - .addInterceptor(authInterceptor) - .addInterceptor(responseLoggingInterceptor) - .build() - - // 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl(clobBaseUrl) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(PolymarketClobApi::class.java) + // 使用钱包地址作为缓存键(每个账户使用不同的 API Key) + return clobApiCache.computeIfAbsent(walletAddress) { + val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress) + + // 添加响应日志拦截器,用于调试 JSON 解析错误 + val responseLoggingInterceptor = ResponseLoggingInterceptor() + + val okHttpClient = createClient() + .addInterceptor(authInterceptor) + .addInterceptor(responseLoggingInterceptor) + .build() + + Retrofit.Builder() + .baseUrl(clobBaseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(PolymarketClobApi::class.java) + } } /** * 创建不带认证的 Polymarket CLOB API 客户端 * 用于不需要认证的查询接口 - * @return PolymarketClobApi 客户端 + * @return PolymarketClobApi 客户端(单例) */ fun createClobApiWithoutAuth(): PolymarketClobApi { - // 添加响应日志拦截器,用于调试 JSON 解析错误 - val responseLoggingInterceptor = ResponseLoggingInterceptor() - - val okHttpClient = createClient() - .addInterceptor(responseLoggingInterceptor) - .build() - - // 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl(clobBaseUrl) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(PolymarketClobApi::class.java) + return clobApiWithoutAuth } /** * 创建 Ethereum RPC API 客户端 + * 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL + * 如果 RPC 不可用,将抛出异常 + * 按 RPC URL 缓存,避免重复创建 * @param rpcUrl RPC 节点 URL * @return EthereumRpcApi 客户端 + * @throws IllegalArgumentException 如果 RPC URL 无效或不可用 */ fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi { - val okHttpClient = createClient().build() + // 确保实际的 RPC URL 以 / 结尾 + val actualRpcUrl = if (rpcUrl.endsWith("/")) { + rpcUrl + } else { + "$rpcUrl/" + } - // 创建 lenient 模式的 Gson - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl(rpcUrl) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(EthereumRpcApi::class.java) + // 使用 RPC URL 作为缓存键 + return rpcApiCache.computeIfAbsent(actualRpcUrl) { + // 验证 RPC 是否可用(仅在新创建时验证) + validateRpcAvailability(actualRpcUrl) + + // 使用固定的 baseUrl(Retrofit 要求 baseUrl 必须以 / 结尾) + val fixedBaseUrl = "https://polyrpc.polyhermes/" + + // 创建 URL 替换拦截器 + val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl) + + val okHttpClient = createClient() + .addInterceptor(urlReplaceInterceptor) + .build() + + Retrofit.Builder() + .baseUrl(fixedBaseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(EthereumRpcApi::class.java) + } + } + + /** + * 验证 RPC 节点是否可用 + * 通过发送一个简单的 eth_blockNumber 请求来验证 + * @param rpcUrl RPC 节点 URL + * @throws IllegalArgumentException 如果 RPC 不可用 + */ + private fun validateRpcAvailability(rpcUrl: String) { + try { + // 解析 URL + val httpUrl = rpcUrl.toHttpUrlOrNull() + ?: throw IllegalArgumentException("无效的 RPC URL: $rpcUrl") + + // 创建 JSON-RPC 请求体 + val jsonRpcRequest = """ + { + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + } + """.trimIndent() + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonRpcRequest.toRequestBody(mediaType) + + // 创建请求 + val request = Request.Builder() + .url(httpUrl) + .post(requestBody) + .header("Content-Type", "application/json") + .build() + + // 创建临时客户端用于验证(使用较短的超时时间) + val testClient = createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + // 发送请求 + val response = testClient.newCall(request).execute() + + if (!response.isSuccessful) { + throw IllegalArgumentException("RPC 节点不可用: HTTP ${response.code} ${response.message}") + } + + val responseBody = response.body?.string() + if (responseBody.isNullOrBlank()) { + throw IllegalArgumentException("RPC 节点响应为空") + } + + // 检查响应是否包含错误 + if (responseBody.contains("\"error\"")) { + throw IllegalArgumentException("RPC 节点返回错误: $responseBody") + } + + // 检查响应是否包含 result + if (!responseBody.contains("\"result\"")) { + throw IllegalArgumentException("RPC 节点响应格式错误: $responseBody") + } + + logger.debug("RPC 节点验证成功: $rpcUrl") + } catch (e: IllegalArgumentException) { + logger.error("RPC 节点验证失败: $rpcUrl - ${e.message}") + throw e + } catch (e: Exception) { + logger.error("RPC 节点验证失败: $rpcUrl - ${e.message}", e) + throw IllegalArgumentException("RPC 节点不可用: ${e.message}", e) + } } /** * 创建 Polymarket Gamma API 客户端 * Gamma API 是公开 API,不需要认证 - * @return PolymarketGammaApi 客户端 + * @return PolymarketGammaApi 客户端(单例) */ fun createGammaApi(): PolymarketGammaApi { - val baseUrl = if (gammaBaseUrl.endsWith("/")) { - gammaBaseUrl.dropLast(1) - } else { - gammaBaseUrl - } - val okHttpClient = createClient().build() - - // 创建 lenient 模式的 Gson - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl("$baseUrl/") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(PolymarketGammaApi::class.java) + return gammaApi } /** * 创建 Polymarket Data API 客户端 * Data API 是公开 API,不需要认证 - * @return PolymarketDataApi 客户端 + * @return PolymarketDataApi 客户端(单例) */ fun createDataApi(): PolymarketDataApi { - val baseUrl = "https://data-api.polymarket.com" - val okHttpClient = createClient() - .followRedirects(true) - .followSslRedirects(true) - .build() - - // 创建 lenient 模式的 Gson - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl("$baseUrl/") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(PolymarketDataApi::class.java) + return dataApi } /** * 创建 Builder Relayer API 客户端 + * 按 relayerUrl 缓存,避免重复创建 * @param relayerUrl Builder Relayer URL * @param apiKey Builder API Key * @param secret Builder Secret @@ -186,57 +327,94 @@ class RetrofitFactory( relayerUrl } - // 添加 Builder 认证拦截器 - val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase) - val okHttpClient = createClient() - .addInterceptor(builderAuthInterceptor) - .build() - - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl("$baseUrl/") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(BuilderRelayerApi::class.java) + // 使用 baseUrl 作为缓存键(注意:如果 API Key 变化,需要清理缓存) + return builderRelayerApiCache.computeIfAbsent(baseUrl) { + // 添加 Builder 认证拦截器 + val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase) + val okHttpClient = createClient() + .addInterceptor(builderAuthInterceptor) + .build() + + Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(BuilderRelayerApi::class.java) + } } /** * 创建 GitHub API 客户端 * GitHub API 是公开 API,不需要认证(但建议使用 token 提高速率限制) * 添加 Accept 头以获取 reactions 数据 - * @return GitHubApi 客户端 + * @return GitHubApi 客户端(单例) */ fun createGitHubApi(): GitHubApi { - val baseUrl = "https://api.github.com" + return githubApi + } + + /** + * 清理缓存(用于测试或配置变更时) + */ + @PreDestroy + fun destroy() { + logger.info("清理 RetrofitFactory 缓存") + clobApiCache.clear() + rpcApiCache.clear() + builderRelayerApiCache.clear() + } + + /** + * 清理指定钱包地址的 CLOB API 缓存 + * 用于 API Key 变更时 + */ + fun clearClobApiCache(walletAddress: String) { + clobApiCache.remove(walletAddress) + logger.debug("已清理 CLOB API 缓存: $walletAddress") + } + + /** + * 清理指定 RPC URL 的 RPC API 缓存 + * 用于 RPC 节点变更时 + */ + fun clearRpcApiCache(rpcUrl: String) { + val actualRpcUrl = if (rpcUrl.endsWith("/")) rpcUrl else "$rpcUrl/" + rpcApiCache.remove(actualRpcUrl) + logger.debug("已清理 RPC API 缓存: $actualRpcUrl") + } +} + +/** + * RPC URL 替换拦截器 + * 用于将固定的 baseUrl 替换为实际的 RPC URL + */ +class RpcUrlReplaceInterceptor( + private val fixedBaseUrl: String, + private val actualRpcUrl: String +) : Interceptor { + private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java) + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val originalUrl = originalRequest.url - // 添加拦截器,设置 Accept 头以获取 reactions 数据 - val githubInterceptor = object : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request().newBuilder() - .header("Accept", "application/vnd.github+json") - .build() - return chain.proceed(request) - } - } + // 将固定 baseUrl 替换为实际的 RPC URL + val originalUrlString = originalUrl.toString() + val newUrlString = originalUrlString.replace(fixedBaseUrl, actualRpcUrl) - val okHttpClient = createClient() - .addInterceptor(githubInterceptor) + // 使用 HttpUrl 解析新 URL,确保格式正确 + val newUrl = newUrlString.toHttpUrlOrNull() + ?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString") + + logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString") + + val newRequest = originalRequest.newBuilder() + .url(newUrl) .build() - val gson = GsonBuilder() - .setLenient() - .create() - - return Retrofit.Builder() - .baseUrl("$baseUrl/") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(GitHubApi::class.java) + return chain.proceed(newRequest) } /** @@ -298,22 +476,35 @@ class ResponseLoggingInterceptor : Interceptor { val responseBody = response.peekBody(2048) val responseBodyString = responseBody.string() - // 检查是否是有效的 JSON - val isJson = responseBodyString.trim().startsWith("{") || - responseBodyString.trim().startsWith("[") + // 检查响应体是否为空 + val isEmpty = responseBodyString.isBlank() - if (!isJson || !response.isSuccessful) { + // 检查是否是有效的 JSON + val trimmedBody = responseBodyString.trim() + val isJson = !isEmpty && ( + trimmedBody.startsWith("{") || + trimmedBody.startsWith("[") + ) + + // 如果响应体为空或不是 JSON,记录警告 + if (isEmpty || !isJson) { + val bodyPreview = if (isEmpty) { + "(空响应体)" + } else { + trimmedBody.take(500) + } logger.warn( "API 响应异常: method=${request.method}, url=${request.url}, " + - "code=${response.code}, isJson=$isJson, " + - "responseBody=${responseBodyString.take(500)}" + "code=${response.code}, isJson=$isJson, isEmpty=$isEmpty, " + + "responseBody=$bodyPreview" ) } } catch (e: Exception) { + // 如果读取响应体失败,记录异常但不影响响应 + logger.debug("读取响应体失败: ${e.message}") } } return response } } - diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index dc75ad0..ef73a28 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -40,23 +40,12 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com polymarket.data-api.base-url=https://data-api.polymarket.com polymarket.gamma.base-url=https://gamma-api.polymarket.com -# Polygon RPC 配置(用于查询链上余额) -# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓 -# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID -polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com} - # Builder Relayer 配置(用于 Gasless 交易) # 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证 # Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中 # 如果未配置,将使用手动发送交易的方式(需要用户支付 gas) polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/} -# 仓位推送配置 -# 轮询间隔(毫秒),默认3秒 -position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000} -# 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接 -position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000} - # 跟单轮询配置 # 轮询间隔(毫秒),默认2秒 copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000} diff --git a/backend/src/main/resources/db/migration/V11__add_max_position_config.sql b/backend/src/main/resources/db/migration/V11__add_max_position_config.sql new file mode 100644 index 0000000..f953963 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__add_max_position_config.sql @@ -0,0 +1,9 @@ +-- ============================================ +-- V11: 添加最大仓位配置字段 +-- 在 copy_trading 表中添加最大仓位金额和最大仓位数量配置 +-- ============================================ + +ALTER TABLE copy_trading +ADD COLUMN max_position_value DECIMAL(20, 8) NULL COMMENT '最大仓位金额(USDC),NULL表示不启用' AFTER max_price, +ADD COLUMN max_position_count INT NULL COMMENT '最大仓位数量,NULL表示不启用' AFTER max_position_value; + diff --git a/backend/src/main/resources/db/migration/V12__create_rpc_node_config.sql b/backend/src/main/resources/db/migration/V12__create_rpc_node_config.sql new file mode 100644 index 0000000..4562b95 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__create_rpc_node_config.sql @@ -0,0 +1,41 @@ +-- 创建 RPC 节点配置表 +CREATE TABLE rpc_node_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', + provider_type VARCHAR(50) NOT NULL COMMENT '服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC', + name VARCHAR(100) NOT NULL COMMENT '节点名称', + http_url VARCHAR(500) NOT NULL COMMENT 'HTTP RPC URL', + ws_url VARCHAR(500) COMMENT 'WebSocket URL (可选)', + api_key VARCHAR(200) COMMENT 'API Key (加密存储)', + enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用', + priority INT DEFAULT 0 COMMENT '优先级(数字越小优先级越高)', + last_check_time BIGINT COMMENT '最后检查时间(毫秒时间戳)', + last_check_status VARCHAR(20) COMMENT '最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN', + response_time_ms INT COMMENT '最后一次响应时间(毫秒)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_enabled_priority (enabled, priority), + INDEX idx_last_check_status (last_check_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Polygon RPC 节点配置表'; + +-- 插入默认公共节点 (PublicNode) +INSERT INTO rpc_node_config ( + provider_type, + name, + http_url, + ws_url, + enabled, + priority, + created_at, + updated_at, + last_check_status +) VALUES ( + 'PUBLIC', + 'PublicNode (Default)', + 'https://polygon.publicnode.com', + 'wss://polygon.publicnode.com', + TRUE, + 999, + UNIX_TIMESTAMP() * 1000, + UNIX_TIMESTAMP() * 1000, + 'UNKNOWN' +); diff --git a/backend/src/main/resources/db/migration/V13__add_price_updated_to_sell_match_record.sql b/backend/src/main/resources/db/migration/V13__add_price_updated_to_sell_match_record.sql new file mode 100644 index 0000000..c4084fa --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__add_price_updated_to_sell_match_record.sql @@ -0,0 +1,8 @@ +-- 添加 price_updated 字段到 sell_match_record 表 +-- 用于标记卖出价格是否已从订单详情中更新 +ALTER TABLE sell_match_record +ADD COLUMN price_updated BOOLEAN DEFAULT FALSE COMMENT '价格是否已更新(从订单详情获取实际成交价)'; + +-- 为已存在的记录设置默认值 +UPDATE sell_match_record SET price_updated = TRUE WHERE price_updated IS NULL; + diff --git a/backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql b/backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql new file mode 100644 index 0000000..ffe9661 --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__add_notification_sent_to_copy_order_tracking.sql @@ -0,0 +1,8 @@ +-- 添加 notification_sent 字段到 copy_order_tracking 表 +-- 用于标记买入订单是否已发送通知 +ALTER TABLE copy_order_tracking +ADD COLUMN notification_sent BOOLEAN DEFAULT FALSE COMMENT '是否已发送通知(从订单详情获取实际数据后发送)'; + +-- 为已存在的记录设置默认值(已存在的订单视为已发送通知) +UPDATE copy_order_tracking SET notification_sent = TRUE WHERE notification_sent IS NULL; + diff --git a/backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql b/backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql new file mode 100644 index 0000000..0fc44d3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_leader_buy_quantity_to_copy_order_tracking.sql @@ -0,0 +1,7 @@ +-- 添加 Leader 买入数量字段,用于固定金额模式计算卖出比例 +ALTER TABLE copy_order_tracking +ADD COLUMN leader_buy_quantity DECIMAL(20, 8) DEFAULT NULL COMMENT 'Leader 买入数量(用于固定金额模式计算卖出比例)'; + +-- 对于已有数据,如果无法从 API 查询,设置为 NULL(不影响现有功能) +-- 新创建的记录会在创建时自动填充此字段 + diff --git a/backend/src/main/resources/db/migration/V16__remove_failed_trade_table.sql b/backend/src/main/resources/db/migration/V16__remove_failed_trade_table.sql new file mode 100644 index 0000000..5b43e3d --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__remove_failed_trade_table.sql @@ -0,0 +1,8 @@ +-- ============================================ +-- V16: 删除失败交易记录表 +-- 移除下单失败存储到数据库的功能 +-- ============================================ + +-- 删除失败交易记录表 +DROP TABLE IF EXISTS failed_trade; + diff --git a/backend/src/main/resources/db/migration/V17__add_wallet_type_to_accounts.sql b/backend/src/main/resources/db/migration/V17__add_wallet_type_to_accounts.sql new file mode 100644 index 0000000..0fb5e92 --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__add_wallet_type_to_accounts.sql @@ -0,0 +1,35 @@ +-- ============================================ +-- 添加 wallet_type 字段到 wallet_accounts 表 +-- 用于区分 Magic 和 Safe 两种钱包类型 +-- ============================================ + +-- 使用存储过程检查并添加字段(如果不存在) +DELIMITER $$ + +CREATE PROCEDURE IF NOT EXISTS add_wallet_type_if_not_exists() +BEGIN + DECLARE column_exists INT DEFAULT 0; + + SELECT COUNT(*) INTO column_exists + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wallet_accounts' + AND COLUMN_NAME = 'wallet_type'; + + IF column_exists = 0 THEN + ALTER TABLE wallet_accounts + ADD COLUMN wallet_type VARCHAR(20) NOT NULL DEFAULT 'magic' COMMENT '钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)' AFTER is_enabled; + END IF; +END$$ + +DELIMITER ; + +-- 执行存储过程 +CALL add_wallet_type_if_not_exists(); + +-- 删除存储过程 +DROP PROCEDURE IF EXISTS add_wallet_type_if_not_exists; + +-- 为现有账户设置默认walletType为magic(如果值为NULL) +UPDATE wallet_accounts SET wallet_type = 'magic' WHERE wallet_type IS NULL OR wallet_type = ''; + diff --git a/deploy.sh b/deploy.sh index 41a1ebe..3191b1e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -71,9 +71,6 @@ SERVER_PORT=80 # MySQL 端口(可选,用于外部连接,默认 3307 避免与本地 MySQL 冲突) MYSQL_PORT=3307 -# Polygon RPC -POLYGON_RPC_URL=https://polygon-rpc.com - # JWT 密钥(已自动生成随机值,生产环境建议修改) JWT_SECRET=${JWT_SECRET} @@ -159,7 +156,18 @@ deploy() { # 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量 warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest" else - info "构建 Docker 镜像(本地构建,版本号将显示为 dev)..." + # 获取当前分支名作为版本号 + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev") + # 如果分支名包含 /,替换为 -(Docker tag 不支持 /) + DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-') + + info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION})..." + + # 设置构建参数(通过环境变量传递给 docker-compose.yml) + export VERSION=${DOCKER_VERSION} + export GIT_TAG=${DOCKER_VERSION} + export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes + docker-compose build fi @@ -199,7 +207,9 @@ main() { info "访问地址: http://localhost:${SERVER_PORT:-80}" echo "" if [ "$USE_DOCKER_HUB" != "true" ]; then - info "提示:本地构建的版本号显示为 'dev'" + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev") + DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-') + info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}" info "生产环境推荐使用 Docker Hub 镜像:" info " ./deploy.sh --use-docker-hub" info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest" diff --git a/docker-compose.prod.env.example b/docker-compose.prod.env.example index 912ecbd..040738a 100644 --- a/docker-compose.prod.env.example +++ b/docker-compose.prod.env.example @@ -38,12 +38,6 @@ SERVER_PORT=80 # Optional, for external connections, default 3307 to avoid conflicts with local MySQL MYSQL_PORT=3307 -# ============================================ -# Polygon RPC -# ============================================ -# Polygon 网络 RPC 地址 / Polygon network RPC address -POLYGON_RPC_URL=https://polygon-rpc.com - # ============================================ # 安全配置 / Security Configuration # ============================================ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e7b27bf..6ecb88c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -24,7 +24,6 @@ services: - DB_USERNAME=${DB_USERNAME:-root} - DB_PASSWORD=${DB_PASSWORD:-} - SERVER_PORT=8000 - - POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com} # ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败 # 请在 .env 文件中设置,或通过环境变量传入 # 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET) diff --git a/docker-compose.yml b/docker-compose.yml index 6841b5d..d7f94c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,11 +8,11 @@ services: build: context: . dockerfile: Dockerfile - # 本地构建时可以传递版本号参数(可选) - # args: - # VERSION: ${VERSION:-dev} - # GIT_TAG: ${GIT_TAG:-} - # GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes + # 本地构建时可以传递版本号参数(自动使用当前分支名) + args: + VERSION: ${VERSION:-dev} + GIT_TAG: ${GIT_TAG:-${VERSION:-dev}} + GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes} container_name: polyhermes ports: - "${SERVER_PORT:-80}:80" @@ -22,7 +22,6 @@ services: - DB_USERNAME=${DB_USERNAME:-root} - DB_PASSWORD=${DB_PASSWORD:-} - SERVER_PORT=8000 - - POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com} # ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败 # 请在 .env 文件中设置,或通过环境变量传入 # 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET) diff --git a/docs/en/DEPLOYMENT.md b/docs/en/DEPLOYMENT.md index 45af3e1..66cb8de 100644 --- a/docs/en/DEPLOYMENT.md +++ b/docs/en/DEPLOYMENT.md @@ -104,6 +104,38 @@ export IMAGE_TAG=v1.0.0 # In docker-compose.prod.yml use: image: wrbug/polyhermes:${IMAGE_TAG:-latest} ``` +**Update Docker Version**: + +When a new version is released, you can update using the following steps: + +```bash +# 1. Stop currently running containers +docker-compose -f docker-compose.prod.yml down + +# 2. Pull the latest version image (or specific version) +# Update to latest version +docker pull wrbug/polyhermes:latest + +# Or update to specific version (e.g., v1.0.1) +docker pull wrbug/polyhermes:v1.0.1 + +# 3. If using a specific version, modify the image tag in docker-compose.prod.yml +# Edit docker-compose.prod.yml, change image to: +# image: wrbug/polyhermes:v1.0.1 + +# 4. Restart services +docker-compose -f docker-compose.prod.yml up -d + +# 5. Check logs to confirm services started normally +docker-compose -f docker-compose.prod.yml logs -f +``` + +**Notes**: +- ⚠️ It is recommended to backup the database before updating (if using MySQL in Docker Compose) +- ⚠️ Service will be briefly interrupted during update, recommend updating during off-peak hours +- ✅ Using `docker-compose pull` can automatically pull the latest image and update (if using `latest` tag) +- ✅ View available versions: Visit [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) or [GitHub Releases](https://github.com/WrBug/PolyHermes/releases) + 2. **Local Build Deployment (Development Environment)** Suitable for development environments or scenarios requiring custom builds. @@ -131,7 +163,6 @@ DB_USERNAME=root DB_PASSWORD=your_password_here SPRING_PROFILES_ACTIVE=prod SERVER_PORT=80 -POLYGON_RPC_URL=https://polygon-rpc.com JWT_SECRET=your-jwt-secret-key-change-in-production ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production EOF @@ -361,7 +392,6 @@ DB_USERNAME=root DB_PASSWORD=your_password_here SPRING_PROFILES_ACTIVE=prod SERVER_PORT=8000 -POLYGON_RPC_URL=https://polygon-rpc.com JWT_SECRET=your-jwt-secret-key-change-in-production ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production EOF @@ -522,7 +552,6 @@ serve -s dist -l 3000 | `DB_USERNAME` | Database username | `root` | Yes (Production) | | `DB_PASSWORD` | Database password | - | Yes (Production) | | `SERVER_PORT` | Server port | `8000` | No | -| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` | No | | `JWT_SECRET` | JWT secret key | - | Yes (Production) | | `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | Yes (Production) | diff --git a/docs/en/DEVELOPMENT.md b/docs/en/DEVELOPMENT.md index 86516d3..6529684 100644 --- a/docs/en/DEVELOPMENT.md +++ b/docs/en/DEVELOPMENT.md @@ -100,9 +100,6 @@ spring.datasource.password=${DB_PASSWORD:password} # Server port server.port=${SERVER_PORT:8000} -# Polygon RPC -polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com} - # JWT secret jwt.secret=${JWT_SECRET:change-me-in-production} diff --git a/docs/zh/DEPLOYMENT.md b/docs/zh/DEPLOYMENT.md index 91c28a2..1fdc385 100644 --- a/docs/zh/DEPLOYMENT.md +++ b/docs/zh/DEPLOYMENT.md @@ -102,6 +102,42 @@ export IMAGE_TAG=v1.0.0 # 在 docker-compose.prod.yml 中使用: image: wrbug/polyhermes:${IMAGE_TAG:-latest} ``` +**更新 Docker 版本**: + +当有新版本发布时,可以通过以下步骤更新: + +```bash +# 1. 停止当前运行的容器 +docker-compose -f docker-compose.prod.yml down + +# 2. 拉取最新版本的镜像(或指定版本) +# 更新到最新版本 +docker pull wrbug/polyhermes:latest + +# 或更新到特定版本(例如 v1.0.1) +docker pull wrbug/polyhermes:v1.0.1 + +# 3. 如果使用特定版本,需要修改 docker-compose.prod.yml 中的镜像标签 +# 编辑 docker-compose.prod.yml,将 image 改为: +# image: wrbug/polyhermes:v1.0.1 + +# 4. 重新启动服务 +docker-compose -f docker-compose.prod.yml up -d + +# 5. 查看日志确认服务正常启动 +docker-compose -f docker-compose.prod.yml logs -f +``` + +**注意事项**: +- ⚠️ **备份数据库(强烈推荐)**: + - 备份不是必须的,但强烈推荐,特别是生产环境 + - Docker 更新不会删除数据(数据存储在独立的数据卷中) + - 但数据库结构可能会变更,如果迁移失败,备份可以帮助恢复 + - 备份命令:`docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql` +- ⚠️ 更新过程中服务会短暂中断,建议在低峰期进行 +- ✅ 使用 `docker-compose pull` 可以自动拉取最新镜像并更新(如果使用 `latest` 标签) +- ✅ 查看可用版本:访问 [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) 或 [GitHub Releases](https://github.com/WrBug/PolyHermes/releases) + 2. **本地构建部署(开发环境)** 适用于开发环境或需要自定义构建的场景。 @@ -129,7 +165,6 @@ DB_USERNAME=root DB_PASSWORD=your_password_here SPRING_PROFILES_ACTIVE=prod SERVER_PORT=80 -POLYGON_RPC_URL=https://polygon-rpc.com JWT_SECRET=your-jwt-secret-key-change-in-production ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production EOF @@ -361,7 +396,6 @@ DB_USERNAME=root DB_PASSWORD=your_password_here SPRING_PROFILES_ACTIVE=prod SERVER_PORT=8000 -POLYGON_RPC_URL=https://polygon-rpc.com JWT_SECRET=your-jwt-secret-key-change-in-production ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production EOF @@ -522,7 +556,6 @@ serve -s dist -l 3000 | `DB_USERNAME` | 数据库用户名 | `root` | 是(生产) | | `DB_PASSWORD` | 数据库密码 | - | 是(生产) | | `SERVER_PORT` | 服务器端口 | `8000` | 否 | -| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` | 否 | | `JWT_SECRET` | JWT 密钥 | - | 是(生产) | | `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | 是(生产) | diff --git a/docs/zh/DEVELOPMENT.md b/docs/zh/DEVELOPMENT.md index a4e2771..b5fad19 100644 --- a/docs/zh/DEVELOPMENT.md +++ b/docs/zh/DEVELOPMENT.md @@ -93,9 +93,6 @@ spring.datasource.password=${DB_PASSWORD:password} # 服务器端口 server.port=${SERVER_PORT:8000} -# Polygon RPC -polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com} - # JWT 密钥 jwt.secret=${JWT_SECRET:change-me-in-production} diff --git a/docs/zh/copy-trading-logic-summary.md b/docs/zh/copy-trading-logic-summary.md new file mode 100644 index 0000000..78836d4 --- /dev/null +++ b/docs/zh/copy-trading-logic-summary.md @@ -0,0 +1,289 @@ +# 跟单买卖逻辑简易文档 + +## 一、跟单信号检测 + +### 1.1 监控方式 + +系统使用**轮询方式**监控 Leader 的交易活动: + +- **数据源**:Polymarket Data API 的 `/activity` 接口 +- **轮询间隔**:默认 2 秒(可配置) +- **查询参数**: + - `user`: Leader 钱包地址 + - `type`: `["TRADE"]`(只查询交易类型) + - `limit`: 100(每次最多查询 100 条) + - `sortBy`: `TIMESTAMP` + - `sortDirection`: `DESC`(按时间戳降序,最新的在前) + +### 1.2 增量检测机制 + +通过 **diff 算法**检测新增交易: + +1. **首次轮询**: + - 查询最近 100 条交易 + - 缓存所有交易 ID(不处理) + - 标记首次轮询完成 + +2. **后续轮询**: + - 查询最近 100 条交易 + - 与缓存的交易 ID 集合进行 diff + - 找出新增的交易 ID + - 处理新增交易 + - 更新缓存(添加新增的交易 ID) + +3. **去重机制**: + - 使用 `leaderId + tradeId` 作为唯一标识 + - 在 `processed_trade` 表中记录已处理的交易 + - 避免重复处理同一笔交易 + +### 1.3 交易数据转换 + +从 `UserActivityResponse` 转换为 `TradeResponse`: + +```kotlin +TradeResponse( + id = activity.transactionHash, // 交易ID(用于去重) + market = activity.conditionId, // 市场ID + side = activity.side, // "BUY" 或 "SELL" + price = activity.price, // 交易价格 + size = activity.size, // 交易数量 + timestamp = activity.timestamp, // 时间戳(秒) + user = activity.proxyWallet, // 用户钱包地址 + outcomeIndex = activity.outcomeIndex, // 结果索引(0=第一个outcome,1=第二个outcome) + outcome = activity.outcome // 结果名称 +) +``` + +### 1.4 信号触发流程 + +``` +轮询任务启动 + ↓ +定期轮询所有 Leader(每 2 秒) + ↓ +查询 Leader 活动(/activity 接口) + ↓ +转换为 TradeResponse + ↓ +diff 检测新增交易 + ↓ +调用 processTrade() 处理交易 + ↓ +根据 side 字段判断: + - "BUY" → processBuyTrade() + - "SELL" → processSellTrade() +``` + +## 二、订单构建 + +### 2.1 买入订单构建流程 + +#### 2.1.1 前置检查 + +1. **查找跟单关系**: + - 查询所有启用且支持该 Leader 的跟单配置 + - 验证账户 API 凭证是否配置 + - 验证账户是否启用 + +2. **获取 Token ID**: + - 使用 `outcomeIndex` 和 `market` 获取 tokenId + - 支持多元市场(不限于 YES/NO) + +3. **计算买入数量**: + - **RATIO 模式**:`买入数量 = Leader 数量 × 跟单比例` + - **FIXED 模式**:`买入数量 = 固定金额 / 买入价格` + +4. **过滤条件检查**: + - 价格区间检查 + - 仓位限制检查 + - 市场分类检查 + - 订单簿检查(获取最佳卖单价格) + +5. **价格调整**: + - 应用价格容忍度(`priceTolerance`) + - 买入价格 = Leader 价格 × (1 + 容忍度) + - 确保调整后的价格不低于最佳卖单价格 + +#### 2.1.2 订单签名 + +使用 `OrderSigningService.createAndSignOrder()` 创建并签名订单: + +1. **计算订单金额**: + - **BUY 订单**: + - `makerAmount = price × size`(USDC 金额,最多 2 位小数) + - `takerAmount = size`(shares 数量,最多 4 位小数) + - 转换为 wei(6 位小数) + +2. **生成订单参数**: + - `salt`: 时间戳(毫秒) + - `maker`: 代理钱包地址(proxyAddress) + - `signer`: 从私钥推导的签名地址 + - `taker`: 零地址(0x0000...) + - `tokenId`: 从 outcomeIndex 获取 + - `makerAmount`: 计算出的 maker 金额(wei) + - `takerAmount`: 计算出的 taker 金额(wei) + - `expiration`: "0"(永不过期) + - `nonce`: "0" + - `feeRateBps`: "0" + - `side`: "BUY" + - `signatureType`: 2(Browser Wallet) + +3. **EIP-712 签名**: + - 编码域分隔符(Exchange Contract + Chain ID) + - 编码订单消息哈希 + - 计算结构化数据哈希 + - 使用私钥签名(r + s + v) + +#### 2.1.3 创建订单请求 + +构建 `NewOrderRequest`: + +```kotlin +NewOrderRequest( + order = signedOrder, // 签名的订单对象 + owner = account.apiKey, // API Key + orderType = "FAK", // Fill-And-Kill(允许部分成交,未成交部分立即取消) + deferExec = false // 立即执行 +) +``` + +#### 2.1.4 提交订单 + +1. **创建 CLOB API 客户端**(带认证): + - 使用账户的 API Key、Secret、Passphrase + - 解密 API 凭证 + +2. **调用 API 创建订单**: + - `POST /orders` + - 带重试机制(最多重试 2 次) + - 每次重试都重新生成 salt 并重新签名 + +3. **记录订单跟踪**: + - 保存到 `copy_order_tracking` 表 + - 记录买入订单 ID、数量、价格等信息 + - 状态:`filled` + +### 2.2 卖出订单构建流程 + +#### 2.2.1 前置检查 + +1. **查找跟单关系**: + - 查询所有启用且支持该 Leader 的跟单配置 + - 验证是否支持卖出(`supportSell = true`) + +2. **计算需要匹配的数量**: + - `需要匹配数量 = Leader 卖出数量 × 跟单比例` + +3. **查找未匹配的买入订单**: + - 使用 `outcomeIndex` 匹配(支持多元市场) + - 按 FIFO 顺序(先进先出) + - 查询 `copy_order_tracking` 表中未匹配的订单 + +4. **计算实际可卖出数量**: + - 按 FIFO 顺序匹配 + - 支持部分匹配(一个买入订单可以被多次卖出匹配) + +#### 2.2.2 价格计算 + +1. **优先使用订单簿 bestBid**: + - 查询订单簿(`getOrderbookByTokenId`) + - 获取最佳买单价格(bestBid) + - 使用 bestBid 作为卖出价格 + +2. **备选方案**: + - 如果获取订单簿失败,使用 Leader 价格 + - 卖出价格 = Leader 价格 × 0.9(固定按 90% 计算) + +#### 2.2.3 订单签名 + +与买入订单类似,但: +- `side`: "SELL" +- **SELL 订单金额计算**: + - `makerAmount = size`(shares 数量,最多 4 位小数) + - `takerAmount = price × size`(USDC 金额,使用原始价格计算) + +#### 2.2.4 创建订单请求 + +与买入订单相同: +- `orderType`: "FAK" +- `deferExec`: false + +#### 2.2.5 提交订单 + +1. **调用 API 创建卖出订单**(带重试机制) + +2. **更新买入订单状态**: + - 更新 `copy_order_tracking` 表中的 `remainingQuantity` + - 如果完全匹配,状态更新为 `fully_matched` + - 如果部分匹配,状态更新为 `partially_matched` + +3. **记录匹配关系**: + - 保存到 `sell_match_record` 表(卖出匹配记录) + - 保存到 `sell_match_detail` 表(匹配明细,包含盈亏计算) + +## 三、关键配置 + +### 3.1 跟单配置参数 + +- `copyMode`: 跟单模式("RATIO" 或 "FIXED") +- `copyRatio`: 跟单比例(RATIO 模式) +- `fixedAmount`: 固定金额(FIXED 模式) +- `priceTolerance`: 价格容忍度(买入时使用) +- `supportSell`: 是否支持卖出 +- `enabled`: 是否启用 + +### 3.2 订单类型 + +- **FAK (Fill-And-Kill)**: + - 允许部分成交 + - 未成交部分立即取消 + - 快速响应 Leader 交易,避免订单长期挂单导致价格不匹配 + +### 3.3 重试机制 + +- **最多重试次数**:2 次(首次 + 1 次重试) +- **重试延迟**:3 秒 +- **重试策略**:每次重试都重新生成 salt 并重新签名,确保签名唯一性 + +## 四、数据流向 + +``` +Leader 交易(链上) + ↓ +Polymarket Data API (/activity) + ↓ +轮询服务(CopyTradingPollingService) + ↓ +交易处理服务(CopyOrderTrackingService) + ↓ +订单签名服务(OrderSigningService) + ↓ +CLOB API (POST /orders) + ↓ +订单跟踪表(copy_order_tracking) +``` + +## 五、注意事项 + +1. **去重机制**: + - 使用 `leaderId + tradeId` 作为唯一标识 + - 在 `processed_trade` 表中记录已处理的交易 + - 避免重复处理同一笔交易 + +2. **价格调整**: + - 买入时应用价格容忍度(提高买入价格) + - 卖出时优先使用订单簿 bestBid,失败则使用 Leader 价格的 90% + +3. **订单簿检查**: + - 买入前检查订单簿中是否有可匹配的卖单 + - 确保调整后的买入价格不低于最佳卖单价格 + +4. **FIFO 匹配**: + - 卖出时按买入时间顺序匹配(先进先出) + - 支持部分匹配 + +5. **错误处理**: + - 订单创建失败时记录到 `failed_trade` 表 + - 支持重试机制(最多 2 次) + - 发送失败通知(如果配置了 `pushFailedOrders`) + diff --git a/docs/zh/copy-trading-monitor-strategy.md b/docs/zh/copy-trading-monitor-strategy.md new file mode 100644 index 0000000..ac00d25 --- /dev/null +++ b/docs/zh/copy-trading-monitor-strategy.md @@ -0,0 +1,653 @@ +# 跟单监听策略:链上 WebSocket + 轮询并行方案 + +## 一、方案概述 + +系统**同时运行**两种监听方式,并行处理,哪个数据先返回就用哪个: + +1. **链上 WebSocket 监听**:通过 Polygon RPC 的 `eth_subscribe` 实时监听链上交易 +2. **轮询监听**:通过 Polymarket Data API 定期轮询交易记录 + +**核心特点**: +- 两种方式**并行运行**,不互相排斥 +- WS 断开时**不断重试连接**,不停止轮询 +- 哪个数据先返回就用哪个,确保最快响应 +- 通过去重机制确保同一笔交易只处理一次 + +## 二、关键流程图 + +``` +系统启动 + ↓ +同时启动两种监听方式 + ↓ + ├─→ 链上 WS 监听(并行) + │ ↓ + │ 尝试连接 WS RPC + │ ↓ + │ 连接成功? + │ ├─→ 是 → 订阅 Leader 钱包地址 + │ │ (eth_subscribe: USDC Transfer + ERC1155 Transfer) + │ │ ↓ + │ │ 实时接收交易日志 + │ │ ↓ + │ │ 解析交易 receipt + │ │ ↓ + │ │ 转换为 TradeResponse + │ │ ↓ + │ │ 调用 processTrade()(去重检查) + │ │ ↓ + │ │ 连接断开? + │ │ ├─→ 是 → 等待重连延迟 → 重试连接(循环) + │ │ └─→ 否 → 继续监听 + │ │ + │ └─→ 否 → 等待重连延迟 → 重试连接(循环) + │ + └─→ 轮询监听(并行) + ↓ + 定期轮询 (每 2 秒) + ↓ + 查询 /activity 接口 + ↓ + diff 检测新增交易 + ↓ + 调用 processTrade()(去重检查) + ↓ + 继续轮询(循环) +``` + +## 三、并行处理流程 + +``` +交易发生 + ↓ + ├─→ WS 监听(实时,秒级) + │ ↓ + │ 收到链上日志 + │ ↓ + │ 解析并转换为 TradeResponse + │ ↓ + │ 调用 processTrade() + │ ↓ + │ 去重检查(processed_trade 表) + │ ├─→ 已处理 → 跳过 + │ └─→ 未处理 → 处理交易 + │ + └─→ 轮询监听(延迟,2秒间隔) + ↓ + 轮询到新交易 + ↓ + 转换为 TradeResponse + ↓ + 调用 processTrade() + ↓ + 去重检查(processed_trade 表) + ├─→ 已处理 → 跳过(WS 已处理) + └─→ 未处理 → 处理交易 +``` + +## 三、实现方案 + +### 3.1 服务架构 + +``` +CopyTradingMonitorService (主服务) + ├─→ OnChainWsService (链上 WS 监听,独立运行) + └─→ CopyTradingPollingService (轮询监听,独立运行) +``` + +**关键点**: +- 两个服务**独立运行**,互不影响 +- 主服务负责启动和协调两个服务 +- 两个服务都调用同一个 `processTrade()` 方法 +- 去重由 `processTrade()` 内部处理 + +### 3.2 WS 重连机制 + +**重连策略**: +1. WS 连接断开时,**不停止轮询服务** +2. 等待重连延迟(如 3 秒) +3. 自动重试连接 +4. 连接成功后重新订阅所有 Leader +5. 如果连接失败,继续重试(无限重试) + +**重连流程**: +``` +WS 连接断开 + ↓ +记录断开日志 + ↓ +等待重连延迟(3秒) + ↓ +尝试重新连接 + ↓ +连接成功? + ├─→ 是 → 重新订阅所有 Leader → 继续监听 + └─→ 否 → 等待重连延迟 → 继续重试(循环) +``` + +**关键实现**: +- 使用协程或后台线程持续重试 +- 不阻塞主流程 +- 轮询服务继续运行,不受 WS 状态影响 + +### 3.3 去重机制 + +**去重标识**: +- 使用 `leaderId + transactionHash` 作为唯一标识 +- 在 `processed_trade` 表中记录已处理的交易 + +**去重流程**: +``` +收到交易数据(WS 或轮询) + ↓ +调用 processTrade(leaderId, trade, source) + ↓ +检查 processed_trade 表 + ├─→ 已存在 → 跳过处理(返回成功) + └─→ 不存在 → 继续处理 + ↓ + 处理交易(创建订单等) + ↓ + 保存到 processed_trade 表 + ├─→ leaderId + ├─→ leaderTradeId (transactionHash) + ├─→ tradeType (BUY/SELL) + ├─→ source (onchain-ws / polling) + └─→ status (SUCCESS/FAILED) +``` + +**并发安全**: +- 使用数据库唯一约束(`leaderId + leaderTradeId`) +- 处理唯一约束冲突(并发情况下可能多个请求同时处理同一笔交易) +- 如果冲突,再次查询确认状态 + +### 3.4 链上 WS 监听实现要点 + +**订阅参数**: +- 钱包地址:Leader 的 `leaderAddress` +- 订阅类型: + - USDC Transfer(`from` 或 `to` 为钱包地址) + - ERC1155 TransferSingle/Batch(`from` 或 `to` 为钱包地址) + +**消息处理流程**: +1. 接收 `eth_subscription` 消息 +2. 提取 `transactionHash` +3. 调用 RPC 获取交易 receipt +4. 解析 USDC Transfer 和 ERC1155 Transfer 日志 +5. 计算交易方向(BUY/SELL)、数量、价格 +6. 调用 Gamma API 补齐市场元数据(conditionId、outcomeIndex 等) +7. 转换为 `TradeResponse` +8. 调用 `processTrade(leaderId, trade, "onchain-ws")` 处理交易(去重由内部处理) + +**重连实现**: +```kotlin +// 伪代码示例 +while (isActive) { + try { + // 尝试连接 + val ws = connectWebSocket() + + // 订阅所有 Leader + subscribeAllLeaders(ws) + + // 监听消息 + ws.listen { message -> + handleMessage(message) + } + + // 连接断开,等待重连 + waitReconnectDelay() + } catch (e: Exception) { + logger.warn("WS 连接失败,等待重连: ${e.message}") + waitReconnectDelay() + } +} +``` + +### 3.5 轮询监听实现要点 + +**轮询流程**: +1. 定期查询 `/activity` 接口(每 2 秒) +2. 通过 diff 检测新增交易 +3. 转换为 `TradeResponse` +4. 调用 `processTrade(leaderId, trade, "polling")` 处理交易(去重由内部处理) + +**关键点**: +- 轮询服务**独立运行**,不受 WS 状态影响 +- 即使 WS 正常工作,轮询也继续运行(作为备份) +- 去重机制确保不会重复处理同一笔交易 + +## 四、配置参数 + +### 4.1 RPC 配置获取 + +**RPC 配置从后台配置中读取**,通过 `RpcNodeService` 获取: + +```kotlin +// 获取 HTTP RPC URL +val httpUrl = rpcNodeService.getHttpUrl() + +// 获取 WebSocket RPC URL +val wsUrl = rpcNodeService.getWsUrl() + +// 获取可用节点配置(包含完整信息) +val nodeResult = rpcNodeService.getAvailableNode() +if (nodeResult.isSuccess) { + val node = nodeResult.getOrNull() + val httpUrl = node?.httpUrl + val wsUrl = node?.wsUrl +} +``` + +**配置管理**: +- **RPC 节点配置存储在数据库**(`rpc_node_config` 表),不从配置文件读取 +- 支持多个节点配置,按优先级选择 +- 支持健康检查,自动选择可用节点 +- 前端可以通过系统设置页面配置 RPC 节点 +- 配置变更后,WS 重连时会自动使用新配置 + +**配置字段**: +- `httpUrl`: HTTP RPC 地址 +- `wsUrl`: WebSocket RPC 地址(可选) +- `enabled`: 是否启用 +- `priority`: 优先级(数字越小优先级越高) +- `lastCheckStatus`: 最后检查状态(HEALTHY/UNHEALTHY/UNKNOWN) + +### 4.2 WS 连接配置 + +```properties +# WS 连接超时(毫秒) +polygen.ws.connect.timeout=5000 + +# WS 重连延迟(毫秒) +polygen.ws.reconnect.delay=3000 +``` + +### 4.3 WS 重连配置 + +```properties +# WS 重连延迟(毫秒) +polygen.ws.reconnect.delay=3000 + +# WS 重连最大延迟(毫秒,指数退避上限) +polygen.ws.reconnect.max.delay=60000 + +# WS 重连是否启用指数退避 +polygen.ws.reconnect.exponential.backoff=true +``` + +### 4.4 合约地址配置 + +```properties +# USDC 合约地址 +usdc.contract.address=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 + +# ERC1155 合约地址(Polymarket) +erc1155.contract.address=0x4d97dcd97ec945f40cf65f87097ace5ea0476045 +``` + +## 五、并行方案优势 + +### 5.1 双重保障 + +- **实时性**:WS 提供秒级实时通知 +- **可靠性**:轮询作为备份,确保不遗漏交易 +- **容错性**:WS 断开时轮询继续工作,WS 恢复后自动继续 + +### 5.2 性能优化 + +- **最快响应**:哪个数据先返回就用哪个 +- **负载均衡**:两种方式并行,减少单点压力 +- **去重保护**:确保同一笔交易只处理一次 + +### 5.3 实现简单 + +- **无需切换逻辑**:两种方式始终运行 +- **独立管理**:WS 和轮询各自管理自己的状态 +- **易于维护**:逻辑清晰,易于调试 + +## 六、注意事项 + +1. **WS 重连策略**: + - WS 断开时**不断重试**,不停止轮询 + - 使用指数退避策略,避免频繁重连 + - 记录重连日志,便于监控 + +2. **去重机制**: + - 使用 `leaderId + transactionHash` 作为唯一标识 + - 在 `processTrade()` 方法内部统一处理去重 + - 使用数据库唯一约束确保并发安全 + +3. **性能考虑**: + - WS 模式下需要为每个 Leader 订阅日志 + - 大量 Leader 时可能需要优化订阅策略(批量订阅) + - 轮询间隔可以适当调整(默认 2 秒) + +4. **错误处理**: + - WS 连接失败不影响轮询服务 + - 记录详细的错误日志,便于排查问题 + - 两种方式的错误独立处理,互不影响 + +5. **元数据补齐**: + - 链上数据不包含市场元数据(conditionId、outcomeIndex 等) + - 需要通过 Gamma API 或内部元数据服务补齐 + - 轮询数据已包含元数据,无需额外补齐 + +6. **数据源标识**: + - WS 数据:`source = "onchain-ws"` + - 轮询数据:`source = "polling"` + - 便于统计和分析不同数据源的处理情况 + +## 七、实现要点 + +### 7.1 主服务启动 + +```kotlin +@Service +class CopyTradingMonitorService( + private val rpcNodeService: RpcNodeService, + private val onChainWsService: OnChainWsService, + private val pollingService: CopyTradingPollingService +) { + @PostConstruct + fun init() { + scope.launch { + // 同时启动两种监听方式 + launch { onChainWsService.start() } // WS 监听(独立协程) + launch { pollingService.start() } // 轮询监听(独立协程) + } + } +} +``` + +### 7.2 WS 服务实现(从后台配置获取 RPC) + +```kotlin +@Service +class OnChainWsService( + private val rpcNodeService: RpcNodeService, + private val copyOrderTrackingService: CopyOrderTrackingService +) { + suspend fun start() { + while (isActive) { + try { + // 从后台配置获取 WS RPC URL + val wsUrl = rpcNodeService.getWsUrl() + val httpUrl = rpcNodeService.getHttpUrl() + + // 连接并订阅 + connectAndSubscribe(wsUrl, httpUrl) + + // 连接成功后持续监听 + waitForDisconnect() + } catch (e: Exception) { + logger.warn("WS 连接失败,等待重连: ${e.message}") + delay(reconnectDelay) + } + } + } + + private suspend fun connectAndSubscribe(wsUrl: String, httpUrl: String) { + // 使用 wsUrl 和 httpUrl 连接和订阅 + // ... + } +} +``` + +**关键点**: +- 每次重连时都从 `RpcNodeService` 获取最新的 RPC 配置 +- 如果配置变更,下次重连时会自动使用新配置 +- 支持多个节点配置,自动选择可用节点 + +### 7.3 RPC 配置变更处理 + +**配置变更时的处理**: +- WS 连接断开时,下次重连会从 `RpcNodeService` 获取最新配置 +- 如果当前使用的节点不可用,`getAvailableNode()` 会自动选择下一个可用节点 +- 支持动态切换节点,无需重启服务 + +**节点选择策略**: +1. 优先使用 `lastCheckStatus = HEALTHY` 的节点 +2. 按 `priority` 排序,数字越小优先级越高 +3. 如果所有节点都不可用,返回失败(不降级到默认节点,因为默认节点可能也不可用) + +### 7.4 线程安全保证(单实例推荐方案) + +`processTrade` 方法需要保证线程安全,因为: +- WS 和轮询可能同时处理同一笔交易 +- 多个协程可能并发调用 `processTrade` +- 需要确保同一笔交易只处理一次 + +**推荐方案:使用 Mutex(应用级锁)** + +对于单实例部署,**最轻量的方案是使用 Kotlin 协程的 Mutex**: +- ✅ 无需额外依赖(不需要 Redis) +- ✅ 性能开销小(内存锁,无网络开销) +- ✅ 实现简单(Kotlin 标准库) +- ✅ 协程友好(支持 suspend 函数) + +**实现代码**: + +```kotlin +@Service +open class CopyOrderTrackingService( + // ... 其他依赖 +) { + // 使用 Mutex 保证线程安全(按交易ID锁定) + private val tradeMutexMap = ConcurrentHashMap() + + /** + * 获取或创建 Mutex(按交易ID) + */ + private fun getMutex(leaderId: Long, tradeId: String): Mutex { + val key = "${leaderId}_${tradeId}" + return tradeMutexMap.getOrPut(key) { Mutex() } + } + + /** + * 清理不再使用的 Mutex(可选,避免内存泄漏) + */ + private fun cleanupMutex(leaderId: Long, tradeId: String) { + val key = "${leaderId}_${tradeId}" + // 延迟清理,避免频繁创建/删除 + // 可以定期清理或使用 WeakReference + } + + /** + * 处理交易事件(WebSocket 或轮询) + * 使用 Mutex 保证线程安全 + */ + @Transactional + suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result { + // 获取该交易的 Mutex + val mutex = getMutex(leaderId, trade.id) + + return mutex.withLock { + try { + // 1. 检查是否已处理(去重) + val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId( + leaderId, + trade.id + ) + + if (existingProcessed != null) { + if (existingProcessed.status == "FAILED") { + return@withLock Result.success(Unit) + } + return@withLock Result.success(Unit) + } + + // 检查是否已记录为失败交易 + val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId( + leaderId, + trade.id + ) + if (failedTrade != null) { + return@withLock Result.success(Unit) + } + + // 2. 处理交易逻辑 + val result = when (trade.side.uppercase()) { + "BUY" -> processBuyTrade(leaderId, trade) + "SELL" -> processSellTrade(leaderId, trade) + else -> { + logger.warn("未知的交易方向: ${trade.side}") + Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}")) + } + } + + if (result.isFailure) { + logger.error( + "处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}", + result.exceptionOrNull() + ) + return@withLock result + } + + // 3. 标记为已处理(成功状态) + // 由于使用了 Mutex,这里不会出现并发冲突 + try { + val processed = ProcessedTrade( + leaderId = leaderId, + leaderTradeId = trade.id, + tradeType = trade.side.uppercase(), + source = source, + status = "SUCCESS", + processedAt = System.currentTimeMillis() + ) + processedTradeRepository.save(processed) + } catch (e: Exception) { + // 理论上不会发生,但保留异常处理作为兜底 + if (isUniqueConstraintViolation(e)) { + val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId( + leaderId, + trade.id + ) + if (existing != null) { + logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}") + return@withLock Result.success(Unit) + } + } else { + throw e + } + } + + Result.success(Unit) + } catch (e: Exception) { + logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) + Result.failure(e) + } + } + } +} +``` + +**关键点**: +1. **按交易ID锁定**:每个交易使用独立的 Mutex,不同交易可以并行处理 +2. **Mutex 复用**:使用 `ConcurrentHashMap` 缓存 Mutex,避免频繁创建 +3. **协程友好**:`Mutex.withLock` 是 suspend 函数,不会阻塞线程 +4. **性能优化**:只锁定同一笔交易,不影响其他交易的并发处理 + +**当前实现(基于数据库唯一约束,作为兜底)**: + +```kotlin +@Transactional +suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result { + // 1. 检查是否已处理(去重) + val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId( + leaderId, + trade.id + ) + + if (existingProcessed != null) { + return Result.success(Unit) // 已处理,跳过 + } + + // 2. 处理交易逻辑 + val result = when (trade.side.uppercase()) { + "BUY" -> processBuyTrade(leaderId, trade) + "SELL" -> processSellTrade(leaderId, trade) + else -> Result.failure(IllegalArgumentException("未知的交易方向")) + } + + if (result.isFailure) { + return result + } + + // 3. 标记为已处理(使用数据库唯一约束保证并发安全) + try { + val processed = ProcessedTrade( + leaderId = leaderId, + leaderTradeId = trade.id, + tradeType = trade.side.uppercase(), + source = source, + status = "SUCCESS", + processedAt = System.currentTimeMillis() + ) + processedTradeRepository.save(processed) + } catch (e: Exception) { + // 处理唯一约束冲突(并发情况下可能发生) + if (isUniqueConstraintViolation(e)) { + // 再次检查确认状态 + val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId( + leaderId, + trade.id + ) + if (existing != null) { + logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}") + return Result.success(Unit) + } + } else { + throw e + } + } + + return Result.success(Unit) +} +``` + +**线程安全机制**: + +1. **数据库唯一约束**: + - `UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id)` + - 确保同一笔交易只能插入一次 + - 即使两个线程同时通过检查,也只有一个能成功保存 + +2. **事务隔离**: + - 使用 `@Transactional` 注解 + - 默认隔离级别(通常是 READ_COMMITTED) + - 保证事务内的数据一致性 + +3. **异常处理**: + - 捕获唯一约束冲突异常 + - 重新查询确认状态 + - 避免重复处理 + +**潜在问题**: + +虽然数据库唯一约束可以防止重复插入,但在检查 `existingProcessed` 和保存 `processed` 之间存在时间窗口(TOCTOU),可能导致: +- 两个线程同时通过检查 +- 两个线程都执行 `processBuyTrade` 或 `processSellTrade` +- 虽然只有一个能成功保存 `processed`,但可能创建了重复的订单 + +**其他方案(不推荐,仅作参考)**: + +**方案 1:使用数据库锁(SELECT FOR UPDATE)** +- ❌ 需要修改 Repository 方法 +- ❌ 增加数据库负载 +- ❌ 不适合高并发场景 + +**方案 2:使用分布式锁(Redis,仅适用于多实例)** +- ❌ 需要 Redis 依赖 +- ❌ 有网络开销 +- ❌ 单实例场景不需要 + +**总结**: + +- ✅ **单实例部署推荐**:使用 **Mutex(应用级锁)** + - 最轻量:无需额外依赖,性能开销小 + - 最简单:Kotlin 标准库,实现简单 + - 最高效:内存锁,无网络开销,支持协程并发 +- ❌ **不推荐**:仅依赖数据库唯一约束(存在 TOCTOU 问题,可能创建重复订单) +- ❌ **不推荐**:数据库锁或分布式锁(单实例场景过于复杂) + diff --git a/docs/zh/smart-money-analysis.md b/docs/zh/smart-money-analysis.md new file mode 100644 index 0000000..0b69605 --- /dev/null +++ b/docs/zh/smart-money-analysis.md @@ -0,0 +1,835 @@ +# Polymarket 聪明钱分析方案 + +## 1. 概述 + +聪明钱(Smart Money)分析是指识别和跟踪在 Polymarket 平台上表现优异的交易者,通过分析他们的交易行为、持仓和盈亏表现,来辅助投资决策。 + +## 2. 核心分析维度 + +### 2.1 交易表现指标 + +#### 2.1.1 胜率(Win Rate) +- **定义**:盈利交易数 / 总交易数 +- **计算方式**: + - 通过 `getUserActivity` API 获取用户历史交易 + - 筛选 `type = "TRADE"` 的活动 + - 计算每笔交易的盈亏(通过买入价和卖出价) + - 统计盈利交易数和总交易数 + +#### 2.1.2 平均盈亏比(Average PnL Ratio) +- **定义**:平均盈利金额 / 平均亏损金额 +- **计算方式**: + - 分别计算盈利交易和亏损交易的平均金额 + - 计算比值 + +#### 2.1.3 总盈亏(Total PnL) +- **定义**:所有已实现盈亏的总和 +- **数据来源**: + - 通过 `getPositions` API 获取 `realizedPnl` + - 或通过 `getUserActivity` API 计算历史交易的累计盈亏 + +#### 2.1.4 未实现盈亏(Unrealized PnL) +- **定义**:当前持仓的浮动盈亏 +- **数据来源**: + - 通过 `getPositions` API 获取 `cashPnl`(当前盈亏) + - 或通过 `currentValue - initialValue` 计算 + +#### 2.1.5 收益率(Return Rate) +- **定义**:总盈亏 / 总投入 +- **计算方式**: + - 总投入 = 所有买入交易的总金额 + - 总盈亏 = 已实现盈亏 + 未实现盈亏 + - 收益率 = 总盈亏 / 总投入 + +### 2.2 交易行为指标 + +#### 2.2.1 交易频率(Trading Frequency) +- **定义**:单位时间内的交易次数 +- **计算方式**: + - 通过 `getUserActivity` API 获取指定时间范围内的交易数 + - 计算日均/周均交易次数 + +#### 2.2.2 持仓周期(Holding Period) +- **定义**:平均持仓时间 +- **计算方式**: + - 跟踪每笔买入和对应的卖出时间 + - 计算平均持仓天数 + +#### 2.2.3 市场偏好(Market Preference) +- **定义**:交易者偏好的市场类型 +- **计算方式**: + - 统计交易者在不同分类(sports、crypto)的交易分布 + - 统计交易者偏好的市场主题 + +#### 2.2.4 仓位规模(Position Size) +- **定义**:平均单笔交易金额 +- **计算方式**: + - 通过 `getUserActivity` API 获取 `usdcSize` + - 计算平均交易金额 + +### 2.3 风险指标 + +#### 2.3.1 最大回撤(Maximum Drawdown) +- **定义**:从峰值到谷值的最大跌幅 +- **计算方式**: + - 跟踪账户价值的时序变化 + - 计算每个峰值的回撤幅度 + - 取最大值 + +#### 2.3.2 夏普比率(Sharpe Ratio) +- **定义**:风险调整后的收益率 +- **计算方式**: + - 收益率标准差 / 平均收益率 + - 需要足够的历史数据 + +#### 2.3.3 胜率稳定性(Win Rate Stability) +- **定义**:不同时间段胜率的一致性 +- **计算方式**: + - 按时间段(如每月)计算胜率 + - 计算胜率的方差或标准差 + +## 3. 数据收集方法 + +### 3.1 使用 Polymarket Data API + +#### 3.1.1 获取用户仓位 +```kotlin +// 接口:GET /positions +// 参数: +// - user: 用户钱包地址(必需) +// - market: 市场ID(可选) +// - limit: 限制数量(可选) +// - offset: 偏移量(可选) +// - sortBy: 排序字段(可选,如 "currentValue") +// - sortDirection: 排序方向(可选,如 "desc") + +val positions = dataApi.getPositions( + user = walletAddress, + limit = 100, + sortBy = "currentValue", + sortDirection = "desc" +) +``` + +**返回数据包含**: +- `currentValue`: 当前仓位价值 +- `cashPnl`: 当前盈亏(未实现) +- `realizedPnl`: 已实现盈亏 +- `percentPnl`: 盈亏百分比 +- `avgPrice`: 平均买入价 +- `curPrice`: 当前价格 + +#### 3.1.2 获取用户活动(交易历史) +```kotlin +// 接口:GET /activity +// 参数: +// - user: 用户钱包地址(必需) +// - type: 活动类型(可选,如 ["TRADE"]) +// - side: 交易方向(可选,如 "BUY" 或 "SELL") +// - start: 开始时间戳(可选) +// - end: 结束时间戳(可选) +// - limit: 限制数量(可选) +// - offset: 偏移量(可选) + +val activities = dataApi.getUserActivity( + user = walletAddress, + type = listOf("TRADE"), + side = "BUY", + start = startTimestamp, + end = endTimestamp, + limit = 1000 +) +``` + +**返回数据包含**: +- `type`: 活动类型(TRADE、SPLIT、MERGE、REDEEM等) +- `side`: 交易方向(BUY、SELL) +- `size`: 交易数量 +- `usdcSize`: 交易金额(USDC) +- `price`: 交易价格 +- `timestamp`: 交易时间戳 +- `title`: 市场标题 +- `slug`: 市场标识 + +#### 3.1.3 获取仓位总价值 +```kotlin +// 接口:GET /value +// 参数: +// - user: 用户钱包地址(必需) +// - market: 市场ID列表(可选) + +val totalValue = dataApi.getTotalValue( + user = walletAddress, + market = listOf("market1", "market2") +) +``` + +### 3.2 使用 Polymarket CLOB API + +#### 3.2.1 获取交易记录 +```kotlin +// 接口:GET /data/trades +// 参数: +// - maker_address: 交易者地址(可选) +// - market: 市场ID(可选) +// - before: 之前的时间戳(可选,用于分页) +// - after: 之后的时间戳(可选,用于分页) +// - next_cursor: 分页游标(可选) + +val trades = clobApi.getTrades( + maker_address = walletAddress, + market = marketId, + after = startTimestamp.toString() +) +``` + +**返回数据包含**: +- `id`: 交易ID +- `market`: 市场ID +- `side`: 交易方向(BUY、SELL) +- `price`: 交易价格 +- `size`: 交易数量 +- `timestamp`: 交易时间戳 +- `user`: 交易者地址 + +## 4. 聪明钱识别算法 + +### 4.1 基础筛选条件 + +#### 4.1.1 最低交易次数 +- **条件**:总交易数 >= 50 +- **目的**:确保有足够的数据进行统计分析 + +#### 4.1.2 最低胜率 +- **条件**:胜率 >= 55% +- **目的**:筛选出表现优于随机交易者 + +#### 4.1.3 最低总盈亏 +- **条件**:总盈亏 >= 1000 USDC +- **目的**:筛选出有实际盈利能力的交易者 + +#### 4.1.4 最低收益率 +- **条件**:收益率 >= 20% +- **目的**:筛选出有良好回报的交易者 + +### 4.2 综合评分算法 + +```kotlin +// 聪明钱评分算法 +fun calculateSmartMoneyScore( + winRate: Double, // 胜率(0-1) + totalPnl: Double, // 总盈亏(USDC) + returnRate: Double, // 收益率(0-1) + tradeCount: Int, // 交易次数 + avgPnlRatio: Double // 平均盈亏比 +): Double { + // 权重配置 + val winRateWeight = 0.3 + val totalPnlWeight = 0.25 + val returnRateWeight = 0.25 + val tradeCountWeight = 0.1 + val avgPnlRatioWeight = 0.1 + + // 归一化处理 + val normalizedWinRate = winRate * 100 // 转换为百分比 + val normalizedTotalPnl = min(totalPnl / 10000, 1.0) * 100 // 归一化到0-100 + val normalizedReturnRate = returnRate * 100 // 转换为百分比 + val normalizedTradeCount = min(tradeCount / 200, 1.0) * 100 // 归一化到0-100 + val normalizedAvgPnlRatio = min(avgPnlRatio / 3.0, 1.0) * 100 // 归一化到0-100 + + // 加权求和 + val score = normalizedWinRate * winRateWeight + + normalizedTotalPnl * totalPnlWeight + + normalizedReturnRate * returnRateWeight + + normalizedTradeCount * tradeCountWeight + + normalizedAvgPnlRatio * avgPnlRatioWeight + + return score +} +``` + +### 4.3 排名算法 + +1. **按综合评分排序**:计算所有候选交易者的综合评分,按降序排列 +2. **按分类排名**:分别计算 sports 和 crypto 分类的排名 +3. **按时间段排名**:分别计算最近7天、30天、90天的排名 + +## 5. 实时监控方案 + +### 5.1 监控目标 + +1. **新交易**:监控聪明钱交易者的新买入/卖出交易 +2. **持仓变化**:监控聪明钱交易者的持仓变化 +3. **市场关注**:监控聪明钱交易者关注的新市场 + +### 5.2 实现方式 + +#### 5.2.1 使用 WebSocket(推荐) +- 订阅 Polymarket WebSocket 的 User Channel +- 监听 `event_type = "trade"` 事件 +- 过滤出聪明钱交易者的交易 + +#### 5.2.2 使用轮询 +- 定期调用 `getUserActivity` API(如每5分钟) +- 比较时间戳,识别新交易 +- 使用 `after` 参数只获取新数据 + +### 5.3 跟单集成 + +聪明钱分析可以与现有的跟单系统集成: + +1. **自动添加 Leader**:识别到聪明钱交易者后,自动添加到 Leader 列表 +2. **智能跟单**:根据聪明钱交易者的表现,动态调整跟单比例 +3. **风险控制**:根据聪明钱交易者的风险指标,设置跟单限制 + +## 6. 实现示例 + +### 6.1 聪明钱分析服务 + +```kotlin +@Service +class SmartMoneyAnalysisService( + private val retrofitFactory: RetrofitFactory, + private val blockchainService: BlockchainService +) { + private val logger = LoggerFactory.getLogger(SmartMoneyAnalysisService::class.java) + private val dataApi = retrofitFactory.createDataApi() + + /** + * 分析单个交易者的表现 + */ + suspend fun analyzeTrader(walletAddress: String, days: Int = 90): Result { + return try { + val endTime = System.currentTimeMillis() + val startTime = endTime - (days * 24 * 60 * 60 * 1000L) + + // 1. 获取交易历史 + val activitiesResult = getTradeActivities(walletAddress, startTime, endTime) + if (activitiesResult.isFailure) { + return Result.failure(activitiesResult.exceptionOrNull() ?: Exception("获取交易历史失败")) + } + val activities = activitiesResult.getOrNull() ?: emptyList() + + // 2. 获取当前仓位 + val positionsResult = blockchainService.getPositions(walletAddress) + val positions = if (positionsResult.isSuccess) { + positionsResult.getOrNull() ?: emptyList() + } else { + emptyList() + } + + // 3. 计算指标 + val metrics = calculateMetrics(activities, positions) + + // 4. 计算综合评分 + val score = calculateSmartMoneyScore( + winRate = metrics.winRate, + totalPnl = metrics.totalPnl, + returnRate = metrics.returnRate, + tradeCount = metrics.tradeCount, + avgPnlRatio = metrics.avgPnlRatio + ) + + Result.success( + TraderAnalysis( + walletAddress = walletAddress, + metrics = metrics, + score = score, + positions = positions.size, + lastTradeTime = activities.maxByOrNull { it.timestamp }?.timestamp + ) + ) + } catch (e: Exception) { + logger.error("分析交易者失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取交易活动 + */ + private suspend fun getTradeActivities( + walletAddress: String, + startTime: Long, + endTime: Long + ): Result> { + return try { + val response = dataApi.getUserActivity( + user = walletAddress, + type = listOf("TRADE"), + start = startTime, + end = endTime, + limit = 1000, + sortBy = "timestamp", + sortDirection = "desc" + ) + + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取交易活动失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取交易活动异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 计算交易指标 + */ + private fun calculateMetrics( + activities: List, + positions: List + ): TraderMetrics { + // 分离买入和卖出交易 + val buyTrades = activities.filter { it.side == "BUY" } + val sellTrades = activities.filter { it.side == "SELL" } + + // 计算总交易数 + val tradeCount = activities.size + + // 计算总投入(买入金额总和) + val totalInvested = buyTrades.sumOf { it.usdcSize ?: 0.0 } + + // 计算已实现盈亏(从仓位数据) + val realizedPnl = positions.sumOf { it.realizedPnl ?: 0.0 } + + // 计算未实现盈亏(从仓位数据) + val unrealizedPnl = positions.sumOf { it.cashPnl ?: 0.0 } + + // 计算总盈亏 + val totalPnl = realizedPnl + unrealizedPnl + + // 计算收益率 + val returnRate = if (totalInvested > 0) { + totalPnl / totalInvested + } else { + 0.0 + } + + // 计算胜率(需要匹配买入和卖出交易) + val winRate = calculateWinRate(buyTrades, sellTrades) + + // 计算平均盈亏比 + val avgPnlRatio = calculateAvgPnlRatio(buyTrades, sellTrades) + + return TraderMetrics( + tradeCount = tradeCount, + totalInvested = totalInvested, + totalPnl = totalPnl, + realizedPnl = realizedPnl, + unrealizedPnl = unrealizedPnl, + returnRate = returnRate, + winRate = winRate, + avgPnlRatio = avgPnlRatio + ) + } + + /** + * 计算胜率 + * 通过匹配买入和卖出交易来计算 + */ + private fun calculateWinRate( + buyTrades: List, + sellTrades: List + ): Double { + // 按市场分组买入和卖出交易 + val buyByMarket = buyTrades.groupBy { it.conditionId } + val sellByMarket = sellTrades.groupBy { it.conditionId } + + var winCount = 0 + var totalCount = 0 + + // 遍历每个市场 + buyByMarket.forEach { (marketId, buys) -> + val sells = sellByMarket[marketId] ?: emptyList() + + // 简单匹配:按时间顺序匹配买入和卖出 + // 实际应该使用更精确的匹配算法(如 FIFO) + var buyIndex = 0 + var sellIndex = 0 + + while (buyIndex < buys.size && sellIndex < sells.size) { + val buy = buys[buyIndex] + val sell = sells[sellIndex] + + // 计算盈亏 + val buyPrice = buy.price ?: 0.0 + val sellPrice = sell.price ?: 0.0 + val pnl = (sellPrice - buyPrice) * (buy.size ?: 0.0) + + if (pnl > 0) { + winCount++ + } + totalCount++ + + buyIndex++ + sellIndex++ + } + } + + return if (totalCount > 0) { + winCount.toDouble() / totalCount + } else { + 0.0 + } + } + + /** + * 计算平均盈亏比 + */ + private fun calculateAvgPnlRatio( + buyTrades: List, + sellTrades: List + ): Double { + // 类似胜率计算,分别计算盈利和亏损的平均金额 + val buyByMarket = buyTrades.groupBy { it.conditionId } + val sellByMarket = sellTrades.groupBy { it.conditionId } + + val profits = mutableListOf() + val losses = mutableListOf() + + buyByMarket.forEach { (marketId, buys) -> + val sells = sellByMarket[marketId] ?: emptyList() + + var buyIndex = 0 + var sellIndex = 0 + + while (buyIndex < buys.size && sellIndex < sells.size) { + val buy = buys[buyIndex] + val sell = sells[sellIndex] + + val buyPrice = buy.price ?: 0.0 + val sellPrice = sell.price ?: 0.0 + val pnl = (sellPrice - buyPrice) * (buy.size ?: 0.0) + + if (pnl > 0) { + profits.add(pnl) + } else if (pnl < 0) { + losses.add(-pnl) + } + + buyIndex++ + sellIndex++ + } + } + + val avgProfit = if (profits.isNotEmpty()) { + profits.average() + } else { + 0.0 + } + + val avgLoss = if (losses.isNotEmpty()) { + losses.average() + } else { + 0.0 + } + + return if (avgLoss > 0) { + avgProfit / avgLoss + } else { + if (avgProfit > 0) Double.MAX_VALUE else 0.0 + } + } + + /** + * 计算聪明钱评分 + */ + private fun calculateSmartMoneyScore( + winRate: Double, + totalPnl: Double, + returnRate: Double, + tradeCount: Int, + avgPnlRatio: Double + ): Double { + val winRateWeight = 0.3 + val totalPnlWeight = 0.25 + val returnRateWeight = 0.25 + val tradeCountWeight = 0.1 + val avgPnlRatioWeight = 0.1 + + val normalizedWinRate = winRate * 100 + val normalizedTotalPnl = min(totalPnl / 10000, 1.0) * 100 + val normalizedReturnRate = returnRate * 100 + val normalizedTradeCount = min(tradeCount / 200.0, 1.0) * 100 + val normalizedAvgPnlRatio = min(avgPnlRatio / 3.0, 1.0) * 100 + + val score = normalizedWinRate * winRateWeight + + normalizedTotalPnl * totalPnlWeight + + normalizedReturnRate * returnRateWeight + + normalizedTradeCount * tradeCountWeight + + normalizedAvgPnlRatio * avgPnlRatioWeight + + return score + } + + /** + * 批量分析交易者 + */ + suspend fun analyzeTraders( + walletAddresses: List, + days: Int = 90 + ): Result> { + return try { + val analyses = walletAddresses.mapNotNull { address -> + analyzeTrader(address, days).getOrNull() + } + Result.success(analyses.sortedByDescending { it.score }) + } catch (e: Exception) { + logger.error("批量分析交易者失败: ${e.message}", e) + Result.failure(e) + } + } +} + +/** + * 交易者分析结果 + */ +data class TraderAnalysis( + val walletAddress: String, + val metrics: TraderMetrics, + val score: Double, + val positions: Int, + val lastTradeTime: Long? +) + +/** + * 交易者指标 + */ +data class TraderMetrics( + val tradeCount: Int, + val totalInvested: Double, + val totalPnl: Double, + val realizedPnl: Double, + val unrealizedPnl: Double, + val returnRate: Double, + val winRate: Double, + val avgPnlRatio: Double +) +``` + +### 6.2 聪明钱排名服务 + +```kotlin +@Service +class SmartMoneyRankingService( + private val smartMoneyAnalysisService: SmartMoneyAnalysisService +) { + private val logger = LoggerFactory.getLogger(SmartMoneyRankingService::class.java) + + /** + * 获取聪明钱排名 + */ + suspend fun getRankings( + category: String? = null, // sports 或 crypto + days: Int = 90, + limit: Int = 100 + ): Result> { + return try { + // 1. 获取候选交易者列表 + // 这里需要从某个数据源获取(如数据库、API等) + val candidates = getCandidateTraders(category) + + // 2. 批量分析交易者 + val analysesResult = smartMoneyAnalysisService.analyzeTraders(candidates, days) + if (analysesResult.isFailure) { + return Result.failure(analysesResult.exceptionOrNull() ?: Exception("分析失败")) + } + val analyses = analysesResult.getOrNull() ?: emptyList() + + // 3. 筛选和排序 + val rankings = analyses + .filter { it.metrics.tradeCount >= 50 } // 最低交易次数 + .filter { it.metrics.winRate >= 0.55 } // 最低胜率 + .filter { it.metrics.totalPnl >= 1000 } // 最低总盈亏 + .sortedByDescending { it.score } + .take(limit) + .mapIndexed { index, analysis -> + TraderRanking( + rank = index + 1, + walletAddress = analysis.walletAddress, + score = analysis.score, + metrics = analysis.metrics, + positions = analysis.positions, + lastTradeTime = analysis.lastTradeTime + ) + } + + Result.success(rankings) + } catch (e: Exception) { + logger.error("获取排名失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取候选交易者列表 + * 这里需要实现具体的获取逻辑(如从数据库、API等) + */ + private suspend fun getCandidateTraders(category: String?): List { + // TODO: 实现获取候选交易者的逻辑 + // 可以从以下来源获取: + // 1. 数据库中的 Leader 列表 + // 2. Polymarket 的公开数据 + // 3. 用户提交的交易者地址 + return emptyList() + } +} + +/** + * 交易者排名 + */ +data class TraderRanking( + val rank: Int, + val walletAddress: String, + val score: Double, + val metrics: TraderMetrics, + val positions: Int, + val lastTradeTime: Long? +) +``` + +## 7. 数据存储建议 + +### 7.1 数据库表设计 + +```sql +-- 聪明钱交易者表 +CREATE TABLE smart_money_traders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + wallet_address VARCHAR(42) NOT NULL UNIQUE, + score DOUBLE NOT NULL, + win_rate DOUBLE NOT NULL, + total_pnl DECIMAL(20, 8) NOT NULL, + return_rate DOUBLE NOT NULL, + trade_count INT NOT NULL, + category VARCHAR(20), -- sports 或 crypto + last_analysis_time BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + INDEX idx_score (score DESC), + INDEX idx_category (category), + INDEX idx_last_analysis_time (last_analysis_time) +); + +-- 交易者历史指标表(用于追踪指标变化) +CREATE TABLE trader_metrics_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + wallet_address VARCHAR(42) NOT NULL, + win_rate DOUBLE NOT NULL, + total_pnl DECIMAL(20, 8) NOT NULL, + return_rate DOUBLE NOT NULL, + trade_count INT NOT NULL, + recorded_at BIGINT NOT NULL, + INDEX idx_wallet_address (wallet_address), + INDEX idx_recorded_at (recorded_at) +); +``` + +### 7.2 缓存策略 + +- **Redis 缓存**:缓存聪明钱排名列表,减少数据库查询 +- **缓存过期时间**:建议 1 小时 +- **缓存键**:`smart_money:rankings:{category}:{days}` + +## 8. API 接口设计 + +### 8.1 获取聪明钱排名 + +```kotlin +@PostMapping("/smart-money/rankings") +fun getRankings(@RequestBody request: SmartMoneyRankingsRequest): ResponseEntity> { + // 实现逻辑 +} +``` + +**请求参数**: +```json +{ + "category": "sports", // 可选:sports 或 crypto + "days": 90, // 可选:分析时间范围(天) + "limit": 100, // 可选:返回数量 + "minScore": 50 // 可选:最低评分 +} +``` + +**响应数据**: +```json +{ + "code": 0, + "data": { + "rankings": [ + { + "rank": 1, + "walletAddress": "0x...", + "score": 85.5, + "metrics": { + "tradeCount": 150, + "winRate": 0.65, + "totalPnl": 5000.0, + "returnRate": 0.35, + "avgPnlRatio": 2.5 + }, + "positions": 10, + "lastTradeTime": 1234567890 + } + ], + "total": 100 + }, + "msg": "" +} +``` + +### 8.2 分析单个交易者 + +```kotlin +@PostMapping("/smart-money/analyze") +fun analyzeTrader(@RequestBody request: SmartMoneyAnalyzeRequest): ResponseEntity> { + // 实现逻辑 +} +``` + +**请求参数**: +```json +{ + "walletAddress": "0x...", + "days": 90 +} +``` + +## 9. 注意事项 + +### 9.1 API 限制 + +- **Data API 速率限制**:注意 API 调用频率,避免触发限流 +- **数据延迟**:Data API 的数据可能有延迟,不是实时的 +- **数据完整性**:某些历史数据可能不完整,需要处理缺失数据 + +### 9.2 计算精度 + +- **价格精度**:Polymarket 使用 0.01-0.99 的价格范围,注意精度问题 +- **金额精度**:使用 `BigDecimal` 进行金额计算,避免浮点数误差 +- **时间精度**:注意时间戳的精度(毫秒 vs 秒) + +### 9.3 性能优化 + +- **批量查询**:尽量批量查询多个交易者的数据 +- **缓存策略**:缓存分析结果,避免重复计算 +- **异步处理**:使用异步任务处理大量数据分析 + +### 9.4 数据质量 + +- **数据验证**:验证 API 返回的数据完整性 +- **异常处理**:处理 API 调用失败的情况 +- **数据清洗**:清洗异常数据(如价格为 0、数量为负数等) + +## 10. 后续优化方向 + +1. **机器学习模型**:使用机器学习模型预测交易者未来表现 +2. **实时监控**:集成 WebSocket 实现实时监控聪明钱交易 +3. **跟单推荐**:根据聪明钱分析结果,推荐适合跟单的交易者 +4. **风险预警**:监控聪明钱交易者的风险指标,及时预警 +5. **多维度分析**:增加更多分析维度(如市场类型、时间分布等) + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a775c71..544f8c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "antd": "^5.12.0", "antd-mobile": "^5.34.0", "axios": "^1.6.2", - "ethers": "^6.9.0", + "ethers": "^6.16.0", "i18next": "^25.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -158,6 +158,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1676,6 +1677,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1741,6 +1743,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1937,6 +1940,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2255,6 +2259,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2466,7 +2471,8 @@ "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -2687,6 +2693,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2877,9 +2884,9 @@ } }, "node_modules/ethers": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz", - "integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", "funding": [ { "type": "individual", @@ -2890,6 +2897,7 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], + "license": "MIT", "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -3364,6 +3372,7 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5432,6 +5441,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5443,6 +5453,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6010,6 +6021,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6182,6 +6194,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index 088749d..acd692e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "antd": "^5.12.0", "antd-mobile": "^5.34.0", "axios": "^1.6.2", - "ethers": "^6.9.0", + "ethers": "^6.16.0", "i18next": "^25.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba25cda..c346e48 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' import ApiHealthStatus from './pages/ApiHealthStatus' +import RpcNodeSettings from './pages/RpcNodeSettings' import Announcements from './pages/Announcements' import NbaQuantitativeStrategyList from './pages/NbaQuantitativeStrategyList' import NbaQuantitativeStrategyAdd from './pages/NbaQuantitativeStrategyAdd' @@ -273,7 +274,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> {/* 默认重定向到登录页 */} } /> diff --git a/frontend/src/components/AccountImportForm.tsx b/frontend/src/components/AccountImportForm.tsx new file mode 100644 index 0000000..b1b0a60 --- /dev/null +++ b/frontend/src/components/AccountImportForm.tsx @@ -0,0 +1,372 @@ +import { useState } from 'react' +import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd' +import { QuestionCircleOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useAccountStore } from '../store/accountStore' +import { + getAddressFromPrivateKey, + getAddressFromMnemonic, + getPrivateKeyFromMnemonic, + isValidWalletAddress, + isValidPrivateKey, + isValidMnemonic +} from '../utils' +import { useMediaQuery } from 'react-responsive' + +type ImportType = 'privateKey' | 'mnemonic' +type WalletType = 'magic' | 'safe' + +interface AccountImportFormProps { + form: any + onSuccess?: (accountId: number) => void + onCancel?: () => void + showAlert?: boolean + showCancelButton?: boolean +} + +const AccountImportForm: React.FC = ({ + form, + onSuccess, + onCancel, + showAlert = true, + showCancelButton = true +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { importAccount, loading } = useAccountStore() + const [importType, setImportType] = useState('privateKey') + const [walletType, setWalletType] = useState('safe') + const [derivedAddress, setDerivedAddress] = useState('') + const [addressError, setAddressError] = useState('') + + // 当私钥输入时,自动推导地址 + const handlePrivateKeyChange = (e: React.ChangeEvent) => { + const privateKey = e.target.value.trim() + if (!privateKey) { + setDerivedAddress('') + setAddressError('') + return + } + + // 验证私钥格式 + if (!isValidPrivateKey(privateKey)) { + setAddressError(t('accountImport.privateKeyInvalid')) + setDerivedAddress('') + return + } + + try { + const address = getAddressFromPrivateKey(privateKey) + setDerivedAddress(address) + setAddressError('') + + // 自动填充钱包地址字段 + form.setFieldsValue({ walletAddress: address }) + } catch (error: any) { + setAddressError(error.message || t('accountImport.addressError')) + setDerivedAddress('') + } + } + + // 当助记词输入时,自动推导地址 + const handleMnemonicChange = (e: React.ChangeEvent) => { + const mnemonic = e.target.value.trim() + if (!mnemonic) { + setDerivedAddress('') + setAddressError('') + return + } + + // 验证助记词格式 + if (!isValidMnemonic(mnemonic)) { + setAddressError(t('accountImport.mnemonicInvalid')) + setDerivedAddress('') + return + } + + try { + const address = getAddressFromMnemonic(mnemonic, 0) + setDerivedAddress(address) + setAddressError('') + + // 自动填充钱包地址字段 + form.setFieldsValue({ walletAddress: address }) + } catch (error: any) { + setAddressError(error.message || t('accountImport.addressErrorMnemonic')) + setDerivedAddress('') + } + } + + const handleSubmit = async (values: any) => { + try { + let privateKey: string + let walletAddress: string + + if (importType === 'privateKey') { + // 私钥模式 + privateKey = values.privateKey + walletAddress = values.walletAddress + + // 验证推导的地址和输入的地址是否一致 + if (derivedAddress && walletAddress !== derivedAddress) { + return Promise.reject(new Error(t('accountImport.walletAddressMismatch'))) + } + } else { + // 助记词模式 + if (!values.mnemonic) { + return Promise.reject(new Error(t('accountImport.mnemonicRequired'))) + } + + // 从助记词导出私钥和地址 + privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0) + const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0) + + // 如果用户手动输入了地址,验证是否与推导的地址一致 + if (values.walletAddress) { + if (values.walletAddress !== derivedAddressFromMnemonic) { + // 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址) + walletAddress = derivedAddressFromMnemonic + } else { + // 地址匹配,使用用户输入的地址 + walletAddress = values.walletAddress + } + } else { + // 如果用户没有输入地址,使用推导的地址 + walletAddress = derivedAddressFromMnemonic + } + } + + // 验证钱包地址格式 + if (!isValidWalletAddress(walletAddress)) { + return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) + } + + await importAccount({ + privateKey: privateKey, + walletAddress: walletAddress, + accountName: values.accountName, + walletType: walletType + }) + + // 等待store更新 + await new Promise(resolve => setTimeout(resolve, 100)) + + // 获取新添加的账户ID(通过API获取,因为store可能还没更新) + const { apiService } = await import('../services/api') + const accountsResponse = await apiService.accounts.list() + if (accountsResponse.data.code === 0 && accountsResponse.data.data) { + const newAccounts = accountsResponse.data.data.list || [] + const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress) + if (newAccount && onSuccess) { + onSuccess(newAccount.id) + } else if (onSuccess) { + // 如果找不到账户,仍然调用onSuccess(可能在其他地方处理) + onSuccess(0) + } + } else if (onSuccess) { + // API调用失败,仍然调用onSuccess + onSuccess(0) + } + + return Promise.resolve() + } catch (error: any) { + return Promise.reject(error) + } + } + + return ( + <> + {showAlert && ( + + )} + +
+ + { + setImportType(e.target.value) + setDerivedAddress('') + setAddressError('') + form.setFieldsValue({ walletAddress: '' }) + }} + > + {t('accountImport.privateKey')} + {t('accountImport.mnemonic')} + + + + + {t('accountImport.walletType')}{' '} + + + + + } + > + setWalletType(e.target.value)} + > + + {t('accountImport.walletTypeSafe')} + + + {t('accountImport.walletTypeMagic')} + + + + + {importType === 'privateKey' ? ( + <> + { + if (!value) return Promise.resolve() + if (!isValidPrivateKey(value)) { + return Promise.reject(new Error(t('accountImport.privateKeyInvalid'))) + } + return Promise.resolve() + } + } + ]} + help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} + validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} + > + + + + { + if (!value) return Promise.resolve() + if (!isValidWalletAddress(value)) { + return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) + } + if (derivedAddress && value !== derivedAddress) { + return Promise.reject(new Error(t('accountImport.walletAddressMismatch'))) + } + return Promise.resolve() + } + } + ]} + > + + + + ) : ( + <> + { + if (!value) return Promise.resolve() + if (!isValidMnemonic(value)) { + return Promise.reject(new Error(t('accountImport.mnemonicInvalid'))) + } + return Promise.resolve() + } + } + ]} + help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} + validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} + > + + + + { + if (!value) return Promise.resolve() + if (!isValidWalletAddress(value)) { + return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) + } + if (derivedAddress && value !== derivedAddress) { + return Promise.reject(new Error(t('accountImport.walletAddressMismatchMnemonic'))) + } + return Promise.resolve() + } + } + ]} + > + + + + )} + + + + + + + + + {showCancelButton && onCancel && ( + + )} + + +
+ + ) +} + +export default AccountImportForm + diff --git a/frontend/src/components/AddRpcNodeModal.tsx b/frontend/src/components/AddRpcNodeModal.tsx new file mode 100644 index 0000000..db74685 --- /dev/null +++ b/frontend/src/components/AddRpcNodeModal.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react' +import { Modal, Form, Input, Select, message, Space } from 'antd' +import { LinkOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { RpcNodeAddRequest } from '../types' +import { useTranslation } from 'react-i18next' + +interface AddRpcNodeModalProps { + visible: boolean + onCancel: () => void + onSuccess: () => void +} + +const { Option } = Select + +const AddRpcNodeModal: React.FC = ({ visible, onCancel, onSuccess }) => { + const { t } = useTranslation() + const [form] = Form.useForm() + const [selectedProvider, setSelectedProvider] = useState('CUSTOM') + const [validating, setValidating] = useState(false) + + const providerOptions = [ + { value: 'ALCHEMY', label: t('rpcNodeSettings.providerAlchemy'), url: 'https://dashboard.alchemy.com/' }, + { value: 'INFURA', label: t('rpcNodeSettings.providerInfura'), url: 'https://infura.io/' }, + { value: 'QUICKNODE', label: t('rpcNodeSettings.providerQuickNode'), url: 'https://www.quicknode.com/' }, + { value: 'CHAINSTACK', label: t('rpcNodeSettings.providerChainstack'), url: 'https://chainstack.com/' }, + { value: 'GETBLOCK', label: t('rpcNodeSettings.providerGetBlock'), url: 'https://getblock.io/' }, + { value: 'CUSTOM', label: t('rpcNodeSettings.customNode'), url: '' } + ] + + const handleSubmit = async () => { + try { + const values = await form.validateFields() + setValidating(true) + + const request: RpcNodeAddRequest = { + providerType: values.providerType, + name: values.name, + apiKey: values.apiKey, + httpUrl: values.httpUrl, + wsUrl: values.wsUrl + } + + // 先验证节点 + const validateResponse = await apiService.rpcNodes.validate(request) + + if (validateResponse.data.code === 0 && validateResponse.data.data) { + const result = validateResponse.data.data + + if (!result.valid) { + message.error(`${t('rpcNodeSettings.validateFailed')} ${result.message}`) + setValidating(false) + return + } + + // 验证通过,添加节点 + const addResponse = await apiService.rpcNodes.add(request) + + if (addResponse.data.code === 0) { + message.success(t('rpcNodeSettings.addSuccess')) + form.resetFields() + setSelectedProvider('CUSTOM') + onSuccess() + } else { + message.error(addResponse.data.msg || t('rpcNodeSettings.addFailed')) + } + } else { + message.error(validateResponse.data.msg || t('rpcNodeSettings.validateError')) + } + } catch (error: any) { + if (!error.errorFields) { + message.error(error.message || t('rpcNodeSettings.operationFailed')) + } + } finally { + setValidating(false) + } + } + + const handleCancel = () => { + form.resetFields() + setSelectedProvider('CUSTOM') + onCancel() + } + + const currentProvider = providerOptions.find(p => p.value === selectedProvider) + + return ( + +
+ + + + + + + + + {selectedProvider !== 'CUSTOM' && ( + + {t('rpcNodeSettings.apiKeyLabel')} + {currentProvider?.url && ( + + {t('rpcNodeSettings.getApiKey')} + + )} + + } + name="apiKey" + rules={[{ required: true, message: t('rpcNodeSettings.apiKeyRequired') }]} + > + + + )} + + {selectedProvider === 'CUSTOM' && ( + <> + + + + + + + + + )} +
+
+ ) +} + +export default AddRpcNodeModal diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a94a2a7..6797993 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -19,6 +19,7 @@ import { TwitterOutlined, CheckCircleOutlined, SendOutlined, + ApiOutlined, NotificationOutlined, ThunderboltOutlined, SignalFilled @@ -156,6 +157,11 @@ const Layout: React.FC = ({ children }) => { icon: , label: t('menu.systemOverview') || '通用设置' }, + { + key: '/system-settings/rpc-nodes', + icon: , + label: t('menu.rpcNodes') || 'RPC节点管理' + }, { key: '/system-settings/api-health', icon: , diff --git a/frontend/src/components/LeaderAddForm.tsx b/frontend/src/components/LeaderAddForm.tsx new file mode 100644 index 0000000..d32db5a --- /dev/null +++ b/frontend/src/components/LeaderAddForm.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { Form, Input, Button, Space } from 'antd' +import { apiService } from '../services/api' +import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' +import { isValidWalletAddress } from '../utils' + +interface LeaderAddFormProps { + form: any + onSuccess?: (leaderId: number) => void + onCancel?: () => void + showCancelButton?: boolean +} + +const LeaderAddForm: React.FC = ({ + form, + onSuccess, + onCancel, + showCancelButton = true +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (values: any) => { + setLoading(true) + try { + const response = await apiService.leaders.add({ + leaderAddress: values.leaderAddress.trim(), + leaderName: values.leaderName?.trim() || undefined, + remark: values.remark?.trim() || undefined, + website: values.website?.trim() || undefined + }) + + if (response.data.code === 0) { + if (response.data.data && onSuccess) { + onSuccess(response.data.data.id) + } + return Promise.resolve() + } else { + return Promise.reject(new Error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败')) + } + } catch (error: any) { + return Promise.reject(error) + } finally { + setLoading(false) + } + } + + return ( +
+ { + if (!value) { + return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址')) + } + if (!isValidWalletAddress(value.trim())) { + return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)')) + } + return Promise.resolve() + } + } + ]} + tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'} + > + + + + + + + + + + + + + + + + + + + {showCancelButton && onCancel && ( + + )} + + +
+ ) +} + +export default LeaderAddForm + diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 684a579..d2a9af9 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -65,7 +65,8 @@ "updateSuccess": "Account updated successfully", "updateFailed": "Failed to update account", "getDetailFailed": "Failed to get account details", - "accountIdRequired": "Account ID cannot be empty" + "accountIdRequired": "Account ID cannot be empty", + "proxyAddress": "Proxy Wallet Address" }, "message": { "success": "Operation successful", @@ -198,7 +199,11 @@ "importFailed": "Failed to import account", "derivedAddress": "Derived Address", "addressError": "Cannot derive address from private key", - "addressErrorMnemonic": "Cannot derive address from mnemonic" + "addressErrorMnemonic": "Cannot derive address from mnemonic", + "walletType": "Wallet Type", + "walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)", + "walletTypeMagic": "Magic (Email/Social Login)", + "walletTypeSafe": "Web3 Wallet" }, "leader": { "title": "Leader Management", @@ -236,6 +241,7 @@ "systemSettings": "System", "systemOverview": "Overview", "language": "Language", + "rpcNodes": "RPC Nodes", "apiHealth": "API Health", "builderApiKey": "Builder API Key", "proxy": "Proxy", @@ -426,6 +432,7 @@ }, "leaderAdd": { "title": "Add Leader", + "back": "Back", "leaderAddress": "Leader Wallet Address", "leaderAddressRequired": "Please enter Leader wallet address", "leaderAddressInvalid": "Invalid wallet address format (must be a 42-character address starting with 0x)", @@ -667,6 +674,13 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "positionLimitFilter": "Max Position Limit", + "maxPositionValue": "Max Position Value (USDC)", + "maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxPositionCount": "Max Position Count", + "maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "supportSell": "Support Sell", "supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.", "invalidNumber": "Please enter a valid number" @@ -732,6 +746,13 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "positionLimitFilter": "Max Position Limit", + "maxPositionValue": "Max Position Value (USDC)", + "maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxPositionCount": "Max Position Count", + "maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "configName": "Configuration Name", "configNameRequired": "Please enter configuration name", "configNamePlaceholder": "e.g., Copy Trading Config 1", @@ -752,7 +773,11 @@ "invalidNumber": "Please enter a valid number", "fetchLeaderFailed": "Failed to get Leader list", "fetchTemplateFailed": "Failed to get template list", - "templateName": "Template Name" + "templateName": "Template Name", + "noAccounts": "No accounts", + "importAccount": "Import Account", + "noLeaders": "No Leaders", + "addLeader": "Add Leader" }, "copyTradingEdit": { "title": "Edit Copy Trading Config", @@ -807,6 +832,13 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "positionLimitFilter": "Max Position Limit", + "maxPositionValue": "Max Position Value (USDC)", + "maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxPositionCount": "Max Position Count", + "maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "configName": "Configuration Name", "configNameRequired": "Please enter configuration name", "configNamePlaceholder": "e.g., Copy Trading Config 1", @@ -849,6 +881,8 @@ "orderbookError": "Orderbook Fetch Failed", "orderbookEmpty": "Orderbook Empty", "priceRange": "Price Range Mismatch", + "maxPositionValue": "Exceeds Max Position Value", + "maxPositionCount": "Exceeds Max Position Count", "unknown": "Unknown Reason" }, "noData": "No filtered orders" @@ -1036,5 +1070,66 @@ "averagePnl": "Average PnL", "maxPnl": "Max PnL", "minPnl": "Min PnL" + }, + "rpcNodeSettings": { + "title": "Polygon RPC Node Configuration", + "fetchFailed": "Failed to fetch node list", + "checkHealthSuccess": "Health check completed", + "checkHealthFailed": "Health check failed", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Failed to delete", + "adjustPrioritySuccess": "Priority adjusted successfully", + "adjustPriorityFailed": "Failed to adjust priority", + "priority": "Priority", + "providerType": "Provider", + "name": "Name", + "status": "Status", + "statusHealthy": "Available", + "statusUnhealthy": "Unavailable", + "statusUnknown": "Unknown", + "responseTime": "Response Time", + "enabled": "Enabled", + "actions": "Actions", + "check": "Check", + "enableSuccess": "Enabled successfully", + "disableSuccess": "Disabled successfully", + "updateFailed": "Update failed", + "checkSuccess": "Check completed", + "checkFailed": "Check failed", + "deleteConfirm": "Are you sure you want to delete this node?", + "deleteConfirmOk": "Confirm", + "deleteConfirmCancel": "Cancel", + "delete": "Delete", + "batchCheck": "Batch Check", + "addNode": "Add Node", + "addNodeTitle": "Add RPC Node", + "validateAndAdd": "Validate and Add", + "providerTypeLabel": "Provider Type", + "providerTypeRequired": "Please select provider type", + "nodeNameLabel": "Node Name", + "nodeNameRequired": "Please enter node name", + "nodeNamePlaceholder": "e.g.: My Alchemy Node", + "apiKeyLabel": "API Key", + "getApiKey": "Get API Key", + "apiKeyRequired": "Please enter API Key", + "apiKeyPlaceholder": "Enter your API Key", + "httpUrlLabel": "HTTP RPC URL", + "httpUrlRequired": "Please enter HTTP RPC URL", + "httpUrlInvalid": "Please enter a valid URL", + "httpUrlPlaceholder": "https://polygon-rpc.com", + "wsUrlLabel": "WebSocket URL (Optional)", + "wsUrlInvalid": "Please enter a valid URL", + "wsUrlPlaceholder": "wss://polygon-rpc.com", + "validateFailed": "Node validation failed:", + "addSuccess": "Added successfully", + "addFailed": "Failed to add", + "validateError": "Validation failed", + "operationFailed": "Operation failed", + "customNode": "Custom Node", + "providerAlchemy": "Alchemy", + "providerInfura": "Infura", + "providerQuickNode": "QuickNode", + "providerChainstack": "Chainstack", + "providerGetBlock": "GetBlock" } } diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 0b1325a..c43e9db 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -27,7 +27,10 @@ "total": "共", "items": "条", "prev": "上一页", - "next": "下一页" + "next": "下一页", + "success": "成功", + "failed": "失败", + "close": "关闭" }, "login": { "title": "登录", @@ -45,7 +48,13 @@ "success": "操作成功", "error": "操作失败", "loading": "加载中...", - "noData": "暂无数据" + "noData": "暂无数据", + "loginSuccess": "登录成功", + "loginFailed": "登录失败", + "createUserSuccess": "创建用户成功", + "createUserFailed": "创建用户失败", + "updatePasswordSuccess": "更新密码成功", + "updatePasswordFailed": "更新密码失败" }, "order": { "create": "创建订单", @@ -53,7 +62,47 @@ "cancel": "取消订单", "event": "订单事件", "buy": "买入", - "sell": "卖出" + "sell": "卖出", + "market": "市场", + "status": "状态", + "filled": "已成交", + "remaining": "剩余" + }, + "account": { + "title": "账户管理", + "list": "账户列表", + "detail": "账户详情", + "import": "导入账户", + "update": "更新账户", + "delete": "删除账户", + "accountId": "账户ID", + "accountName": "账户名称", + "accountNamePlaceholder": "账户名称(可选)", + "accountIdRequired": "账户ID不能为空", + "walletAddress": "钱包地址", + "proxyAddress": "代理钱包地址", + "apiCredentials": "API 凭证", + "apiKey": "API Key", + "apiSecret": "API Secret", + "apiPassphrase": "API Passphrase", + "balance": "余额", + "activeOrders": "活跃订单", + "completedOrders": "已完成订单", + "positionCount": "持仓数量", + "totalOrders": "总订单数", + "totalPnl": "总盈亏", + "statistics": "交易统计", + "fullConfig": "完整配置", + "partialConfig": "部分配置", + "notConfigured": "未配置", + "configured": "已配置", + "editTip": "编辑提示", + "editTipDesc": "API 凭证字段留空表示不修改。如需更新 API 凭证,请输入新值;如需保持原值不变,请留空。", + "leaveEmptyToNotModify": "留空表示不修改", + "getDetailFailed": "获取账户详情失败", + "updateSuccess": "更新账户成功", + "updateFailed": "更新账户失败", + "refreshBalance": "刷新余额" }, "accountList": { "title": "账户管理", @@ -150,16 +199,34 @@ "importFailed": "导入账户失败", "derivedAddress": "推导地址", "addressError": "无法从私钥推导地址", - "addressErrorMnemonic": "无法从助记词推导地址" + "addressErrorMnemonic": "无法从助记词推导地址", + "walletType": "钱包类型", + "walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户", + "walletTypeMagic": "Magic(邮箱/社交账号登录)", + "walletTypeSafe": "Web3钱包" }, "leader": { "title": "Leader 管理", "leaderName": "Leader 名称", "leaderAddress": "钱包地址", + "walletAddress": "钱包地址", "category": "分类", + "all": "全部", + "copyTradingCount": "跟单关系数", + "createdAt": "创建时间", + "action": "操作", + "add": "添加", "addLeader": "添加 Leader", + "edit": "编辑", "editLeader": "编辑 Leader", - "deleteLeader": "删除 Leader" + "delete": "删除", + "deleteLeader": "删除 Leader", + "listFailed": "获取 Leader 列表失败", + "deleteSuccess": "删除 Leader 成功", + "deleteFailed": "删除 Leader 失败", + "deleteConfirm": "确定要删除这个 Leader 吗?", + "deleteConfirmDesc": "删除后无法恢复,请谨慎操作!", + "deleteConfirmOk": "确定删除" }, "menu": { "accounts": "账户管理", @@ -174,6 +241,7 @@ "systemSettings": "系统管理", "systemOverview": "概览", "language": "语言", + "rpcNodes": "RPC节点管理", "apiHealth": "API健康", "builderApiKey": "Builder API Key", "proxy": "代理", @@ -221,7 +289,9 @@ "saveSuccess": "保存配置成功", "saveFailed": "保存配置失败", "getFailed": "获取代理配置失败", - "latency": "延迟" + "latency": "延迟", + "hostInvalid": "请输入有效的主机地址", + "portInvalid": "端口必须在 1-65535 之间" }, "languageSettings": { "title": "语言设置", @@ -365,6 +435,7 @@ }, "leaderAdd": { "title": "添加 Leader", + "back": "返回", "leaderAddress": "Leader 钱包地址", "leaderAddressRequired": "请输入 Leader 钱包地址", "leaderAddressInvalid": "钱包地址格式不正确(必须是 0x 开头的 42 位地址)", @@ -606,6 +677,13 @@ "priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。", "minPricePlaceholder": "最低价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)", + "positionLimitFilter": "最大仓位限制", + "maxPositionValue": "最大仓位金额 (USDC)", + "maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)", + "maxPositionCount": "最大仓位数量", + "maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。", "invalidNumber": "请输入有效的数字" @@ -683,6 +761,13 @@ "priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。", "minPricePlaceholder": "最低价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)", + "positionLimitFilter": "最大仓位限制", + "maxPositionValue": "最大仓位金额 (USDC)", + "maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)", + "maxPositionCount": "最大仓位数量", + "maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单", "create": "创建跟单配置", @@ -691,7 +776,11 @@ "invalidNumber": "请输入有效的数字", "fetchLeaderFailed": "获取 Leader 列表失败", "fetchTemplateFailed": "获取模板列表失败", - "templateName": "模板名称" + "templateName": "模板名称", + "noAccounts": "暂无账户", + "importAccount": "导入账户", + "noLeaders": "暂无 Leader", + "addLeader": "添加 Leader" }, "copyTradingEdit": { "title": "编辑跟单配置", @@ -758,6 +847,13 @@ "priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。", "minPricePlaceholder": "最低价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)", + "positionLimitFilter": "最大仓位限制", + "maxPositionValue": "最大仓位金额 (USDC)", + "maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)", + "maxPositionCount": "最大仓位数量", + "maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单", "save": "保存", @@ -788,6 +884,8 @@ "orderbookError": "订单簿获取失败", "orderbookEmpty": "订单簿为空", "priceRange": "价格区间不符", + "maxPositionValue": "超过最大仓位金额", + "maxPositionCount": "超过最大仓位数量", "unknown": "未知原因" }, "noData": "暂无已过滤订单" @@ -975,5 +1073,66 @@ "averagePnl": "平均盈亏", "maxPnl": "最大盈亏", "minPnl": "最小盈亏" + }, + "rpcNodeSettings": { + "title": "Polygon RPC 节点配置", + "fetchFailed": "获取节点列表失败", + "checkHealthSuccess": "健康检查完成", + "checkHealthFailed": "健康检查失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "adjustPrioritySuccess": "调整成功", + "adjustPriorityFailed": "调整失败", + "priority": "优先级", + "providerType": "服务商", + "name": "名称", + "status": "状态", + "statusHealthy": "可用", + "statusUnhealthy": "不可用", + "statusUnknown": "未知", + "responseTime": "响应时间", + "enabled": "启用", + "actions": "操作", + "check": "检查", + "enableSuccess": "启用成功", + "disableSuccess": "禁用成功", + "updateFailed": "更新失败", + "checkSuccess": "检查完成", + "checkFailed": "检查失败", + "deleteConfirm": "确定删除此节点吗?", + "deleteConfirmOk": "确定", + "deleteConfirmCancel": "取消", + "delete": "删除", + "batchCheck": "批量检查", + "addNode": "添加节点", + "addNodeTitle": "添加 RPC 节点", + "validateAndAdd": "验证并添加", + "providerTypeLabel": "服务商类型", + "providerTypeRequired": "请选择服务商类型", + "nodeNameLabel": "节点名称", + "nodeNameRequired": "请输入节点名称", + "nodeNamePlaceholder": "例如: My Alchemy Node", + "apiKeyLabel": "API Key", + "getApiKey": "获取 API Key", + "apiKeyRequired": "请输入 API Key", + "apiKeyPlaceholder": "输入您的 API Key", + "httpUrlLabel": "HTTP RPC URL", + "httpUrlRequired": "请输入 HTTP RPC URL", + "httpUrlInvalid": "请输入有效的 URL", + "httpUrlPlaceholder": "https://polygon-rpc.com", + "wsUrlLabel": "WebSocket URL (可选)", + "wsUrlInvalid": "请输入有效的 URL", + "wsUrlPlaceholder": "wss://polygon-rpc.com", + "validateFailed": "节点验证失败:", + "addSuccess": "添加成功", + "addFailed": "添加失败", + "validateError": "验证失败", + "operationFailed": "操作失败", + "customNode": "自定义节点", + "providerAlchemy": "Alchemy", + "providerInfura": "Infura", + "providerQuickNode": "QuickNode", + "providerChainstack": "Chainstack", + "providerGetBlock": "GetBlock" } } diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 1bd3be7..5ee567f 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -65,7 +65,8 @@ "updateSuccess": "更新賬戶成功", "updateFailed": "更新賬戶失敗", "getDetailFailed": "獲取賬戶詳情失敗", - "accountIdRequired": "賬戶ID不能為空" + "accountIdRequired": "賬戶ID不能為空", + "proxyAddress": "代理錢包地址" }, "message": { "success": "操作成功", @@ -198,7 +199,11 @@ "importFailed": "導入賬戶失敗", "derivedAddress": "推導地址", "addressError": "無法從私鑰推導地址", - "addressErrorMnemonic": "無法從助記詞推導地址" + "addressErrorMnemonic": "無法從助記詞推導地址", + "walletType": "錢包類型", + "walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶", + "walletTypeMagic": "Magic(郵箱/社群帳號登入)", + "walletTypeSafe": "Web3錢包" }, "leader": { "title": "Leader 管理", @@ -236,6 +241,7 @@ "systemSettings": "系統管理", "systemOverview": "概覽", "language": "語言", + "rpcNodes": "RPC節點管理", "apiHealth": "API健康", "builderApiKey": "Builder API Key", "proxy": "代理", @@ -426,6 +432,7 @@ }, "leaderAdd": { "title": "添加 Leader", + "back": "返回", "leaderAddress": "Leader 錢包地址", "leaderAddressRequired": "請輸入 Leader 錢包地址", "leaderAddressInvalid": "錢包地址格式不正確(必須是 0x 開頭的 42 位地址)", @@ -667,6 +674,13 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "positionLimitFilter": "最大倉位限制", + "maxPositionValue": "最大倉位金額 (USDC)", + "maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxPositionCount": "最大倉位數量", + "maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "supportSell": "跟單賣出", "supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。", "invalidNumber": "請輸入有效的數字" @@ -732,6 +746,13 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "positionLimitFilter": "最大倉位限制", + "maxPositionValue": "最大倉位金額 (USDC)", + "maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxPositionCount": "最大倉位數量", + "maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "configName": "配置名", "configNameRequired": "請輸入配置名", "configNamePlaceholder": "例如:跟單配置1", @@ -752,7 +773,11 @@ "invalidNumber": "請輸入有效的數字", "fetchLeaderFailed": "獲取 Leader 列表失敗", "fetchTemplateFailed": "獲取模板列表失敗", - "templateName": "模板名稱" + "templateName": "模板名稱", + "noAccounts": "暫無賬戶", + "importAccount": "導入賬戶", + "noLeaders": "暫無 Leader", + "addLeader": "添加 Leader" }, "copyTradingEdit": { "title": "編輯跟單配置", @@ -807,6 +832,13 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "positionLimitFilter": "最大倉位限制", + "maxPositionValue": "最大倉位金額 (USDC)", + "maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxPositionCount": "最大倉位數量", + "maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "configName": "配置名", "configNameRequired": "請輸入配置名", "configNamePlaceholder": "例如:跟單配置1", @@ -849,6 +881,8 @@ "orderbookError": "訂單簿獲取失敗", "orderbookEmpty": "訂單簿為空", "priceRange": "價格區間不符", + "maxPositionValue": "超過最大倉位金額", + "maxPositionCount": "超過最大倉位數量", "unknown": "未知原因" }, "noData": "暫無已過濾訂單" @@ -1036,5 +1070,66 @@ "averagePnl": "平均盈虧", "maxPnl": "最大盈虧", "minPnl": "最小盈虧" + }, + "rpcNodeSettings": { + "title": "Polygon RPC 節點配置", + "fetchFailed": "獲取節點列表失敗", + "checkHealthSuccess": "健康檢查完成", + "checkHealthFailed": "健康檢查失敗", + "deleteSuccess": "刪除成功", + "deleteFailed": "刪除失敗", + "adjustPrioritySuccess": "調整成功", + "adjustPriorityFailed": "調整失敗", + "priority": "優先級", + "providerType": "服務商", + "name": "名稱", + "status": "狀態", + "statusHealthy": "可用", + "statusUnhealthy": "不可用", + "statusUnknown": "未知", + "responseTime": "響應時間", + "enabled": "啟用", + "actions": "操作", + "check": "檢查", + "enableSuccess": "啟用成功", + "disableSuccess": "禁用成功", + "updateFailed": "更新失敗", + "checkSuccess": "檢查完成", + "checkFailed": "檢查失敗", + "deleteConfirm": "確定刪除此節點嗎?", + "deleteConfirmOk": "確定", + "deleteConfirmCancel": "取消", + "delete": "刪除", + "batchCheck": "批量檢查", + "addNode": "添加節點", + "addNodeTitle": "添加 RPC 節點", + "validateAndAdd": "驗證並添加", + "providerTypeLabel": "服務商類型", + "providerTypeRequired": "請選擇服務商類型", + "nodeNameLabel": "節點名稱", + "nodeNameRequired": "請輸入節點名稱", + "nodeNamePlaceholder": "例如: My Alchemy Node", + "apiKeyLabel": "API Key", + "getApiKey": "獲取 API Key", + "apiKeyRequired": "請輸入 API Key", + "apiKeyPlaceholder": "輸入您的 API Key", + "httpUrlLabel": "HTTP RPC URL", + "httpUrlRequired": "請輸入 HTTP RPC URL", + "httpUrlInvalid": "請輸入有效的 URL", + "httpUrlPlaceholder": "https://polygon-rpc.com", + "wsUrlLabel": "WebSocket URL (可選)", + "wsUrlInvalid": "請輸入有效的 URL", + "wsUrlPlaceholder": "wss://polygon-rpc.com", + "validateFailed": "節點驗證失敗:", + "addSuccess": "添加成功", + "addFailed": "添加失敗", + "validateError": "驗證失敗", + "operationFailed": "操作失敗", + "customNode": "自定義節點", + "providerAlchemy": "Alchemy", + "providerInfura": "Infura", + "providerQuickNode": "QuickNode", + "providerChainstack": "Chainstack", + "providerGetBlock": "GetBlock" } } diff --git a/frontend/src/pages/AccountImport.tsx b/frontend/src/pages/AccountImport.tsx index 6551903..ff43514 100644 --- a/frontend/src/pages/AccountImport.tsx +++ b/frontend/src/pages/AccountImport.tsx @@ -1,150 +1,20 @@ -import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert } from 'antd' +import { Card, Form, Button, Typography } from 'antd' import { ArrowLeftOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' -import { useAccountStore } from '../store/accountStore' -import { - getAddressFromPrivateKey, - getAddressFromMnemonic, - getPrivateKeyFromMnemonic, - isValidWalletAddress, - isValidPrivateKey, - isValidMnemonic -} from '../utils' -import { useMediaQuery } from 'react-responsive' +import { message } from 'antd' +import AccountImportForm from '../components/AccountImportForm' const { Title } = Typography -type ImportType = 'privateKey' | 'mnemonic' - const AccountImport: React.FC = () => { const { t } = useTranslation() const navigate = useNavigate() - const isMobile = useMediaQuery({ maxWidth: 768 }) - const { importAccount, loading } = useAccountStore() const [form] = Form.useForm() - const [importType, setImportType] = useState('privateKey') - const [derivedAddress, setDerivedAddress] = useState('') - const [addressError, setAddressError] = useState('') - // 当私钥输入时,自动推导地址 - const handlePrivateKeyChange = (e: React.ChangeEvent) => { - const privateKey = e.target.value.trim() - if (!privateKey) { - setDerivedAddress('') - setAddressError('') - return - } - - // 验证私钥格式 - if (!isValidPrivateKey(privateKey)) { - setAddressError(t('accountImport.privateKeyInvalid')) - setDerivedAddress('') - return - } - - try { - const address = getAddressFromPrivateKey(privateKey) - setDerivedAddress(address) - setAddressError('') - - // 自动填充钱包地址字段 - form.setFieldsValue({ walletAddress: address }) - } catch (error: any) { - setAddressError(error.message || t('accountImport.addressError')) - setDerivedAddress('') - } - } - - // 当助记词输入时,自动推导地址 - const handleMnemonicChange = (e: React.ChangeEvent) => { - const mnemonic = e.target.value.trim() - if (!mnemonic) { - setDerivedAddress('') - setAddressError('') - return - } - - // 验证助记词格式 - if (!isValidMnemonic(mnemonic)) { - setAddressError(t('accountImport.mnemonicInvalid')) - setDerivedAddress('') - return - } - - try { - const address = getAddressFromMnemonic(mnemonic, 0) - setDerivedAddress(address) - setAddressError('') - - // 自动填充钱包地址字段 - form.setFieldsValue({ walletAddress: address }) - } catch (error: any) { - setAddressError(error.message || t('accountImport.addressErrorMnemonic')) - setDerivedAddress('') - } - } - - const handleSubmit = async (values: any) => { - try { - let privateKey: string - let walletAddress: string - - if (importType === 'privateKey') { - // 私钥模式 - privateKey = values.privateKey - walletAddress = values.walletAddress - - // 验证推导的地址和输入的地址是否一致 - if (derivedAddress && walletAddress !== derivedAddress) { - message.error(t('accountImport.walletAddressMismatch')) - return - } - } else { - // 助记词模式 - if (!values.mnemonic) { - message.error(t('accountImport.mnemonicRequired')) - return - } - - // 从助记词导出私钥和地址 - privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0) - const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0) - - // 如果用户手动输入了地址,验证是否与推导的地址一致 - if (values.walletAddress) { - if (values.walletAddress !== derivedAddressFromMnemonic) { - // 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址) - message.warning(`${t('accountImport.walletAddressMismatchMnemonic')}: ${derivedAddressFromMnemonic}`) - walletAddress = derivedAddressFromMnemonic - } else { - // 地址匹配,使用用户输入的地址 - walletAddress = values.walletAddress - } - } else { - // 如果用户没有输入地址,使用推导的地址 - walletAddress = derivedAddressFromMnemonic - } - } - - // 验证钱包地址格式 - if (!isValidWalletAddress(walletAddress)) { - message.error(t('accountImport.walletAddressInvalid')) - return - } - - await importAccount({ - privateKey: privateKey, - walletAddress: walletAddress, - accountName: values.accountName - }) - - message.success(t('accountImport.importSuccess')) - navigate('/accounts') - } catch (error: any) { - message.error(error.message || t('accountImport.importFailed')) - } + const handleSuccess = async () => { + message.success(t('accountImport.importSuccess')) + navigate('/accounts') } return ( @@ -161,165 +31,13 @@ const AccountImport: React.FC = () => { - - -
- - { - setImportType(e.target.value) - setDerivedAddress('') - setAddressError('') - form.setFieldsValue({ walletAddress: '' }) - }} - > - {t('accountImport.privateKey')} - {t('accountImport.mnemonic')} - - - - {importType === 'privateKey' ? ( - <> - { - if (!value) return Promise.resolve() - if (!isValidPrivateKey(value)) { - return Promise.reject(new Error(t('accountImport.privateKeyInvalid'))) - } - return Promise.resolve() - } - } - ]} - help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} - validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} - > - - - - { - if (!value) return Promise.resolve() - if (!isValidWalletAddress(value)) { - return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) - } - if (derivedAddress && value !== derivedAddress) { - return Promise.reject(new Error(t('accountImport.walletAddressMismatch'))) - } - return Promise.resolve() - } - } - ]} - > - - - - ) : ( - <> - { - if (!value) return Promise.resolve() - if (!isValidMnemonic(value)) { - return Promise.reject(new Error(t('accountImport.mnemonicInvalid'))) - } - return Promise.resolve() - } - } - ]} - help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} - validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} - > - - - - { - if (!value) return Promise.resolve() - if (!isValidWalletAddress(value)) { - return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) - } - if (derivedAddress && value !== derivedAddress) { - return Promise.reject(new Error(t('accountImport.walletAddressMismatchMnemonic'))) - } - return Promise.resolve() - } - } - ]} - > - - - - )} - - - - - - - - - - - - -
+ onSuccess={handleSuccess} + onCancel={() => navigate('/accounts')} + showAlert={true} + showCancelButton={true} + />
) diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index 0f34bd2..a1036e0 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -7,6 +7,7 @@ import { useAccountStore } from '../store/accountStore' import type { Account } from '../types' import { useMediaQuery } from 'react-responsive' import { formatUSDC } from '../utils' +import AccountImportForm from '../components/AccountImportForm' const { Title } = Typography @@ -25,11 +26,20 @@ const AccountList: React.FC = () => { const [editAccount, setEditAccount] = useState(null) const [editForm] = Form.useForm() const [editLoading, setEditLoading] = useState(false) + const [accountImportModalVisible, setAccountImportModalVisible] = useState(false) + const [accountImportForm] = Form.useForm() useEffect(() => { fetchAccounts() }, [fetchAccounts]) + const handleAccountImportSuccess = async () => { + message.success(t('accountImport.importSuccess')) + setAccountImportModalVisible(false) + accountImportForm.resetFields() + fetchAccounts() + } + // 加载所有账户的余额 useEffect(() => { const loadBalances = async () => { @@ -494,7 +504,7 @@ const AccountList: React.FC = () => { + + ) : null + } + > {accounts.map(account => (