Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b73121c5d | |||
| 95930332df | |||
| 185cade11d | |||
| d9443e0d56 | |||
| a097376db7 | |||
| 6e5ccdfe5c | |||
| 21da06d8b0 | |||
| 5a83444b56 | |||
| 591e678f73 | |||
| 1cfdcb88d3 | |||
| 2cc9cf82ba | |||
| 00f0898d98 | |||
| 1de9b5e958 | |||
| c8e422a94b | |||
| 60e9f9235d | |||
| 46baa416f4 | |||
| ecdb8af14a | |||
| d6027e48eb | |||
| eb45013a93 | |||
| f97bd5b9d9 | |||
| df272156cf | |||
| 502b4fb0b7 | |||
| 64f6a2b897 | |||
| f7f2411b9d | |||
| d96eb3e00a | |||
| cc1a732984 | |||
| ec06003157 | |||
| 3d05b13298 | |||
| fe2db11b75 | |||
| 5c18cbd95d | |||
| ad2fa4eef2 | |||
| 8357546f3c | |||
| a62141ea5e | |||
| 9157203653 | |||
| 23fc20763a | |||
| e96a0b6279 | |||
| 85d8619fe7 | |||
| 89fb980da7 | |||
| 9f0b22fab5 | |||
| 5e544cda07 | |||
| b6d66aa81e | |||
| 62669719d9 | |||
| a6ca6cc3ff | |||
| d1f351108f | |||
| c4ac3a8fe0 | |||
| 1580b60d24 | |||
| 7bc059ec34 | |||
| b5908a9bbf | |||
| c20e0138df | |||
| 20acc72ed6 | |||
| a31ed31c98 | |||
| 0625c62e36 | |||
| f8d7276654 | |||
| 442d5778a7 | |||
| cc41df2f24 | |||
| cdadce467e | |||
| 6e4d4f1ef5 | |||
| 9fb295a2f4 | |||
| db16dea333 | |||
| d6f0914eeb | |||
| f0c40533cf | |||
| d393bd7b40 | |||
| 7173d24fe1 |
@@ -36,6 +36,49 @@ jobs:
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "Full tag: $TAG_NAME"
|
||||
|
||||
- name: Send Telegram notification (build started)
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查必要的环境变量
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
# 检查发送结果
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
# 通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
# 通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
@@ -65,3 +108,49 @@ jobs:
|
||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Send Telegram notification
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查必要的环境变量
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_NAME="${{ github.event.release.name }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
REPO_NAME="${{ github.repository }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
# 检查发送结果
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
# 构建成功,通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
# 构建成功,通知失败不应该导致整个 job 失败
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Telegram Notification on PR Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed # 当 PR 被关闭(合并或关闭)时触发
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 只在 PR 被合并到 main 分支时执行
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get PR details
|
||||
id: pr_details
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
|
||||
# 获取 PR 变更的文件列表
|
||||
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
|
||||
|
||||
# 提取 PR 描述(body),保留换行,限制长度
|
||||
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
|
||||
if [ ${#PR_BODY} -gt 500 ]; then
|
||||
PR_BODY="${PR_BODY:0:500}..."
|
||||
fi
|
||||
|
||||
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
|
||||
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$PR_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Send Telegram notification
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查 PR 是否被合并(而不是仅关闭)
|
||||
PR_MERGED="${{ github.event.pull_request.merged }}"
|
||||
if [ "$PR_MERGED" != "true" ]; then
|
||||
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查必要的环境变量
|
||||
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890)
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取 PR 基本信息
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
|
||||
|
||||
# 转义 PR 标题中的 HTML 特殊字符
|
||||
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&/g' | sed 's/</\</g' | sed 's/>/\>/g')
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
# 检查发送结果
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -94,6 +94,13 @@ coverage/
|
||||
test-results/
|
||||
*.test.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Misc
|
||||
*.bak
|
||||
*.backup
|
||||
@@ -102,4 +109,5 @@ test-results/
|
||||
# Submodules and external dependencies
|
||||
clob-client/
|
||||
builder-relayer-client/
|
||||
landing-page/
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PolyHermes
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/quant_tr)
|
||||
[](https://x.com/polyhermes)
|
||||
|
||||
> 🌐 **Language**: [English](README_EN.md) | 中文
|
||||
|
||||
@@ -228,6 +228,25 @@ docker-compose up -d
|
||||
# image: wrbug/polyhermes:v1.0.0
|
||||
```
|
||||
|
||||
**更新 Docker 版本**:
|
||||
|
||||
```bash
|
||||
# 1. 停止当前容器
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. 拉取最新镜像
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 3. 重新启动服务
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 或更新到特定版本(例如 v1.0.1)
|
||||
# 修改 docker-compose.prod.yml 中的镜像标签为: image: wrbug/polyhermes:v1.0.1
|
||||
# 然后执行: docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
详细更新说明请参考:[部署文档 - 更新 Docker 版本](docs/zh/DEPLOYMENT.md#更新-docker-版本)
|
||||
|
||||
2. **本地构建部署(开发环境)**
|
||||
|
||||
```bash
|
||||
@@ -251,7 +270,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=80
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -318,7 +336,6 @@ cd frontend
|
||||
| `DB_USERNAME` | 数据库用户名 | `root` |
|
||||
| `DB_PASSWORD` | 数据库密码 | - |
|
||||
| `SERVER_PORT` | 后端服务端口 | `8000` |
|
||||
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` |
|
||||
| `JWT_SECRET` | JWT 密钥 | - |
|
||||
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
|
||||
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
|
||||
@@ -414,7 +431,7 @@ cd frontend
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/quant_tr)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Polymarket 官网](https://polymarket.com)
|
||||
- [Polymarket API 文档](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+21
-4
@@ -1,7 +1,7 @@
|
||||
# PolyHermes
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/quant_tr)
|
||||
[](https://x.com/polyhermes)
|
||||
|
||||
> 🌐 **Language**: English | [中文](README.md)
|
||||
|
||||
@@ -228,6 +228,25 @@ docker-compose up -d
|
||||
# image: wrbug/polyhermes:v1.0.0
|
||||
```
|
||||
|
||||
**Update Docker Version**:
|
||||
|
||||
```bash
|
||||
# 1. Stop current containers
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. Pull latest image
|
||||
docker pull wrbug/polyhermes:latest
|
||||
|
||||
# 3. Restart services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Or update to specific version (e.g., v1.0.1)
|
||||
# Modify image tag in docker-compose.prod.yml to: image: wrbug/polyhermes:v1.0.1
|
||||
# Then run: docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
For detailed update instructions, please refer to: [Deployment Guide - Update Docker Version](docs/en/DEPLOYMENT.md#update-docker-version)
|
||||
|
||||
2. **Local Build Deployment (Development Environment)**
|
||||
|
||||
```bash
|
||||
@@ -251,7 +270,6 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=your_password_here
|
||||
SPRING_PROFILES_ACTIVE=prod
|
||||
SERVER_PORT=80
|
||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||
EOF
|
||||
@@ -318,7 +336,6 @@ cd frontend
|
||||
| `DB_USERNAME` | Database username | `root` |
|
||||
| `DB_PASSWORD` | Database password | - |
|
||||
| `SERVER_PORT` | Backend service port | `8000` |
|
||||
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` |
|
||||
| `JWT_SECRET` | JWT secret key | - |
|
||||
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
|
||||
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
|
||||
@@ -414,7 +431,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
|
||||
## 🔗 Related Links
|
||||
|
||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/quant_tr)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Polymarket Official Website](https://polymarket.com)
|
||||
- [Polymarket API Documentation](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
# v1.1.5
|
||||
|
||||
## 🔧 功能优化与改进
|
||||
|
||||
### 前端优化
|
||||
|
||||
#### 优化 InputNumber 输入框格式化
|
||||
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
|
||||
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
|
||||
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
|
||||
- 影响范围:跟单配置、模板配置中的所有数值输入框
|
||||
|
||||
#### 优化数字显示格式
|
||||
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100)
|
||||
- 统一所有数值输入框的显示格式,提升用户体验
|
||||
|
||||
### 后端优化
|
||||
|
||||
#### 优化按比例跟单金额计算逻辑
|
||||
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
|
||||
- 对订单金额进行向上取整处理(保留 2 位小数精度)
|
||||
- 自动调整订单数量以满足最小限制要求
|
||||
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
|
||||
- 影响范围:按比例跟单的订单创建逻辑
|
||||
- 技术细节:
|
||||
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
|
||||
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
|
||||
|
||||
#### 增强 copyRatio 精度支持
|
||||
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
|
||||
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%)
|
||||
- 影响的实体:CopyTrading、CopyTradingTemplate
|
||||
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 移除刷新代理钱包接口
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
|
||||
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
|
||||
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
|
||||
- **影响范围**:AccountController、AccountService
|
||||
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
|
||||
|
||||
### 前端跟单比例配置优化
|
||||
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
|
||||
- **最大比例**:增加到 10000%,满足大比例跟单需求
|
||||
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x")
|
||||
- **输入验证**:增强输入验证,确保比例在合理范围内
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:3 个提交
|
||||
- **文件变更**:15 个文件
|
||||
- **代码变更**:+575 行 / -194 行(净增加 381 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
|
||||
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
|
||||
- `CopyTrading.kt` - 增加 copyRatio 精度
|
||||
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
|
||||
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
|
||||
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
|
||||
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
|
||||
|
||||
**前端变更**:
|
||||
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingList.tsx` - 优化比例显示格式
|
||||
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 数据库变更
|
||||
- **迁移脚本**:`V18__increase_copy_ratio_precision.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy`
|
||||
- `POST /api/accounts/refresh-all-proxies`
|
||||
- **无新增接口**
|
||||
|
||||
### 前端变更
|
||||
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
|
||||
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
|
||||
- **显示优化**:跟单模式的比例显示为百分比格式
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
本次版本包含数据库迁移脚本,升级时会自动执行:
|
||||
- 自动增加 `copy_ratio` 字段的精度
|
||||
- 现有数据不受影响,精度升级是向后兼容的
|
||||
|
||||
### 配置变更
|
||||
无需额外配置变更。
|
||||
|
||||
### 兼容性
|
||||
- **向后兼容**:所有变更都是向后兼容的
|
||||
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
|
||||
- **数据兼容**:数据库字段精度升级不会影响现有数据
|
||||
|
||||
## 🎯 主要改进
|
||||
|
||||
1. **优化输入框格式化**:优化数值输入框的显示逻辑
|
||||
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
|
||||
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%)
|
||||
4. **代码清理**:移除不再使用的刷新代理钱包接口
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
|
||||
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和测试用户的反馈与支持!
|
||||
|
||||
---
|
||||
|
||||
# v1.1.2
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🐛 修复内存泄漏问题
|
||||
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
|
||||
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
|
||||
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
|
||||
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
|
||||
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
|
||||
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
|
||||
|
||||
### 📊 市场价格服务优化
|
||||
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
|
||||
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
|
||||
- 如果所有数据源都失败,抛出明确的异常信息
|
||||
- 价格截位到 4 位小数(向下截断,不四舍五入)
|
||||
- 简化代码逻辑,提高查询效率和准确性
|
||||
|
||||
### 🔧 代码架构优化
|
||||
- 统一 Gson 使用,改为依赖注入方式
|
||||
- 在 `GsonConfig` 中统一配置 Gson 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 支持一下!**
|
||||
|
||||
|
||||
+4
-4
@@ -22,9 +22,9 @@ FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具
|
||||
# 安装必要的工具和时区数据
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
apt-get install -y curl tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从构建阶段复制 JAR 文件
|
||||
@@ -41,8 +41,8 @@ EXPOSE 8000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||
CMD curl -f http://localhost:8000/api/system/health || exit 1
|
||||
|
||||
# 启动应用
|
||||
# 启动应用(自动使用系统时区)
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
depends_on:
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.wrbug.polymarketbot
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
class PolymarketBotApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
@@ -29,10 +30,11 @@ data class JsonRpcRequest(
|
||||
|
||||
/**
|
||||
* JSON-RPC 响应
|
||||
* 使用 JsonElement 类型处理 result 字段,可以灵活处理字符串、对象、数组等类型
|
||||
*/
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String? = null,
|
||||
val result: String? = null,
|
||||
val result: JsonElement? = null, // 使用 JsonElement 类型,可以处理任意 JSON 类型
|
||||
val error: JsonRpcError? = null,
|
||||
val id: Int? = null
|
||||
)
|
||||
|
||||
@@ -227,7 +227,7 @@ data class CreateOrderRequest(
|
||||
|
||||
@Deprecated("使用 NewOrderRequest 代替")
|
||||
data class CreateOrdersBatchRequest(
|
||||
val orders: List<CreateOrderRequest>
|
||||
val orders: List<NewOrderRequest>
|
||||
)
|
||||
|
||||
data class CancelOrdersBatchRequest(
|
||||
|
||||
@@ -16,12 +16,14 @@ interface PolymarketGammaApi {
|
||||
* 根据 condition ID 列表获取市场信息
|
||||
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
||||
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
||||
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
|
||||
* @param includeTag 是否包含标签信息
|
||||
* @return 市场信息数组
|
||||
*/
|
||||
@GET("/markets")
|
||||
suspend fun listMarkets(
|
||||
@Query("condition_ids") conditionIds: List<String>? = null,
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
}
|
||||
@@ -51,6 +53,9 @@ data class MarketResponse(
|
||||
val liquidityNum: Double? = null,
|
||||
val lastTradePrice: Double? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null
|
||||
val bestAsk: Double? = null,
|
||||
// 以下字段可能存在于响应中,但不在标准文档中
|
||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* Gson 配置类
|
||||
* 统一配置 Gson 实例,使用 lenient 模式允许解析格式不严格的 JSON
|
||||
*/
|
||||
@Configuration
|
||||
class GsonConfig {
|
||||
|
||||
/**
|
||||
* 创建 Gson Bean
|
||||
* 使用 lenient 模式,允许解析格式不严格的 JSON
|
||||
*/
|
||||
@Bean
|
||||
fun gson(): Gson {
|
||||
return GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor(
|
||||
handler: Any
|
||||
): Boolean {
|
||||
val path = request.requestURI
|
||||
val method = request.method
|
||||
|
||||
// 只拦截POST请求
|
||||
if (method != "POST") {
|
||||
|
||||
// 只拦截 /api/** 路径
|
||||
if (!path.startsWith("/api/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// 排除不需要鉴权的路径
|
||||
if (excludePaths.contains(path)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 只拦截 /api/** 路径
|
||||
if (!path.startsWith("/api/")) {
|
||||
|
||||
// 允许 OPTIONS 请求(CORS 预检请求)
|
||||
if (request.method == "OPTIONS") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.service.ProxyConfigService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -18,7 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
* - 账户 API Key 在调用时动态设置,不在此处配置
|
||||
*/
|
||||
@Configuration
|
||||
class RetrofitConfig {
|
||||
class RetrofitConfig(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private lateinit var clobBaseUrl: String
|
||||
@@ -37,7 +40,7 @@ class RetrofitConfig {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@ class WebMvcConfig(
|
||||
// 再注册JWT认证拦截器
|
||||
registry.addInterceptor(jwtAuthenticationInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
registry.addInterceptor(jwtAuthenticationInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
registry.addInterceptor(jwtAuthenticationInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
-18
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.repository.UserRepository
|
||||
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||
import com.wrbug.polymarketbot.util.JwtUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.server.ServerHttpRequest
|
||||
@@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor
|
||||
|
||||
/**
|
||||
* WebSocket 握手拦截器
|
||||
* 用于验证 JWT token
|
||||
* 优先使用短期票据验证,其次使用 JWT token
|
||||
*/
|
||||
@Component
|
||||
class WebSocketAuthInterceptor(
|
||||
private val jwtUtils: JwtUtils,
|
||||
private val userRepository: UserRepository
|
||||
private val userRepository: UserRepository,
|
||||
private val webSocketTicketService: WebSocketTicketService
|
||||
) : HandshakeInterceptor {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
|
||||
@@ -27,22 +29,36 @@ class WebSocketAuthInterceptor(
|
||||
wsHandler: WebSocketHandler,
|
||||
attributes: MutableMap<String, Any>
|
||||
): Boolean {
|
||||
// 从查询参数或请求头获取 token
|
||||
val token = getTokenFromRequest(request)
|
||||
|
||||
if (token == null) {
|
||||
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}")
|
||||
// 优先使用票据验证(推荐方式,不暴露 JWT)
|
||||
val ticket = getTicketFromRequest(request)
|
||||
if (ticket != null) {
|
||||
val username = webSocketTicketService.validateAndConsumeTicket(ticket)
|
||||
if (username != null) {
|
||||
attributes["username"] = username
|
||||
logger.debug("WebSocket 连接票据认证成功: username=$username")
|
||||
return true
|
||||
}
|
||||
logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)")
|
||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容)
|
||||
val token = getTokenFromRequest(request)
|
||||
|
||||
if (token == null) {
|
||||
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}")
|
||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证 token
|
||||
if (!jwtUtils.validateToken(token)) {
|
||||
logger.warn("WebSocket 连接 token 验证失败: ${request.uri}")
|
||||
logger.warn("WebSocket 连接 token 验证失败")
|
||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// 验证tokenVersion(检查token是否因密码修改而失效)
|
||||
val username = jwtUtils.getUsernameFromToken(token)
|
||||
if (username != null) {
|
||||
@@ -50,21 +66,21 @@ class WebSocketAuthInterceptor(
|
||||
if (user != null) {
|
||||
val tokenVersion = jwtUtils.getTokenVersionFromToken(token)
|
||||
if (tokenVersion == null || tokenVersion != user.tokenVersion) {
|
||||
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}")
|
||||
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username")
|
||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取用户名并存入 attributes,供后续使用
|
||||
attributes["username"] = username
|
||||
logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}")
|
||||
logger.debug("WebSocket 连接 JWT 认证成功: username=$username")
|
||||
} else {
|
||||
logger.warn("WebSocket 连接无法获取用户名: ${request.uri}")
|
||||
logger.warn("WebSocket 连接无法获取用户名")
|
||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -78,7 +94,22 @@ class WebSocketAuthInterceptor(
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中获取 token
|
||||
* 从请求中获取票据
|
||||
*/
|
||||
private fun getTicketFromRequest(request: ServerHttpRequest): String? {
|
||||
val queryParams = request.uri.query ?: return null
|
||||
val params = queryParams.split("&")
|
||||
for (param in params) {
|
||||
val parts = param.split("=", limit = 2)
|
||||
if (parts.size == 2 && parts[0] == "ticket") {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中获取 token(兼容旧方式)
|
||||
* 支持从查询参数 token 或请求头 Authorization 获取
|
||||
*/
|
||||
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
|
||||
@@ -93,13 +124,13 @@ class WebSocketAuthInterceptor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 从请求头获取
|
||||
val authHeader = request.headers.getFirst("Authorization")
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7)
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
|
||||
import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
|
||||
@@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
||||
class WebSocketConfig(
|
||||
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
|
||||
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
|
||||
private val webSocketAuthInterceptor: WebSocketAuthInterceptor
|
||||
private val webSocketAuthInterceptor: WebSocketAuthInterceptor,
|
||||
@Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String
|
||||
) : WebSocketConfigurer {
|
||||
|
||||
|
||||
/**
|
||||
* 获取允许的 WebSocket 来源
|
||||
* 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名
|
||||
* 否则使用 setAllowedOriginPatterns 允许同源访问
|
||||
*/
|
||||
private fun getAllowedOrigins(): Array<String> {
|
||||
return if (allowedOriginsConfig.isNotBlank()) {
|
||||
allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray()
|
||||
} else {
|
||||
emptyArray()
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
||||
val origins = getAllowedOrigins()
|
||||
|
||||
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
|
||||
// 注意:此端点不需要鉴权,因为它只是转发外部数据
|
||||
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
|
||||
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
|
||||
|
||||
val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
|
||||
if (origins.isNotEmpty()) {
|
||||
polymarketHandler.setAllowedOrigins(*origins)
|
||||
} else {
|
||||
// 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全
|
||||
polymarketHandler.setAllowedOriginPatterns("*")
|
||||
}
|
||||
|
||||
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
|
||||
// 支持的频道:position(仓位推送)、order(订单推送,待实现)等
|
||||
// 支持的频道:position(仓位推送)、order(订单推送)等
|
||||
// 需要 JWT 鉴权
|
||||
registry.addHandler(unifiedWebSocketHandler, "/ws")
|
||||
.addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器
|
||||
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
|
||||
val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws")
|
||||
.addInterceptors(webSocketAuthInterceptor)
|
||||
if (origins.isNotEmpty()) {
|
||||
unifiedHandler.setAllowedOrigins(*origins)
|
||||
} else {
|
||||
unifiedHandler.setAllowedOriginPatterns("*")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -15,7 +15,7 @@ import java.math.BigDecimal
|
||||
* 账户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/accounts")
|
||||
@RequestMapping("/api/accounts")
|
||||
class AccountController(
|
||||
private val accountService: AccountService,
|
||||
private val messageSource: MessageSource
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.announcement
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AnnouncementService
|
||||
import com.wrbug.polymarketbot.service.announcement.AnnouncementService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+61
-9
@@ -1,8 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.auth
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AuthService
|
||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.*
|
||||
@RequestMapping("/api/auth")
|
||||
class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val messageSource: MessageSource
|
||||
private val messageSource: MessageSource,
|
||||
private val webSocketTicketService: WebSocketTicketService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
||||
@@ -25,7 +27,10 @@ class AuthController(
|
||||
* 登录接口
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
fun login(@RequestBody request: LoginRequest): ResponseEntity<ApiResponse<LoginResponse>> {
|
||||
fun login(
|
||||
@RequestBody request: LoginRequest,
|
||||
httpRequest: HttpServletRequest
|
||||
): ResponseEntity<ApiResponse<LoginResponse>> {
|
||||
return try {
|
||||
if (request.username.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource))
|
||||
@@ -33,15 +38,19 @@ class AuthController(
|
||||
if (request.password.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource))
|
||||
}
|
||||
|
||||
val result = authService.login(request.username, request.password)
|
||||
|
||||
val ipAddress = getClientIpAddress(httpRequest)
|
||||
val result = authService.login(request.username, request.password, ipAddress)
|
||||
result.fold(
|
||||
onSuccess = { loginResponse ->
|
||||
ResponseEntity.ok(ApiResponse.success(loginResponse))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("登录失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalStateException -> {
|
||||
// 限速或锁定错误
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource))
|
||||
}
|
||||
is IllegalArgumentException -> {
|
||||
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource))
|
||||
@@ -49,15 +58,36 @@ class AuthController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("登录异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端IP地址
|
||||
*/
|
||||
private fun getClientIpAddress(request: HttpServletRequest): String {
|
||||
var ip = request.getHeader("X-Forwarded-For")
|
||||
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
|
||||
ip = request.getHeader("X-Real-IP")
|
||||
}
|
||||
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
|
||||
ip = request.getHeader("Proxy-Client-IP")
|
||||
}
|
||||
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
|
||||
ip = request.remoteAddr
|
||||
}
|
||||
// 处理多个IP的情况
|
||||
if (ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim()
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码接口
|
||||
@@ -132,5 +162,27 @@ class AuthController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 连接票据
|
||||
* 返回一个短期有效(30秒)的一次性票据,用于 WebSocket 连接认证
|
||||
* 避免在 WebSocket URL 中暴露 JWT
|
||||
*/
|
||||
@PostMapping("/ws-ticket")
|
||||
fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<WebSocketTicketResponse>> {
|
||||
return try {
|
||||
// 从请求属性中获取用户名(由 JWT 拦截器设置)
|
||||
val username = httpRequest.getAttribute("username") as? String
|
||||
if (username == null) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||
}
|
||||
|
||||
val ticket = webSocketTicketService.generateTicket(username)
|
||||
ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket)))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 WebSocket 票据异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.CopyTradingService
|
||||
import com.wrbug.polymarketbot.service.FilteredOrderService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 跟单配置管理控制器(钱包-模板关联)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading")
|
||||
@RequestMapping("/api/copy-trading/configs")
|
||||
class CopyTradingController(
|
||||
private val copyTradingService: CopyTradingService,
|
||||
private val filteredOrderService: FilteredOrderService,
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.leaders
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.LeaderService
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaders.LeaderService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.CopyTradingStatisticsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyTradingStatisticsService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.copytrading.templates
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.CopyTradingTemplateService
|
||||
import com.wrbug.polymarketbot.service.copytrading.templates.CopyTradingTemplateService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
+19
-15
@@ -1,11 +1,13 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.markets
|
||||
|
||||
import com.wrbug.polymarketbot.api.LatestPriceResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import com.wrbug.polymarketbot.service.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.math.BigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
@@ -16,18 +18,20 @@ import org.springframework.web.bind.annotation.*
|
||||
* 提供市场相关的数据查询接口(价格、订单簿等)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/markets")
|
||||
@RequestMapping("/api/markets")
|
||||
class MarketController(
|
||||
private val accountService: AccountService,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val marketPriceService: MarketPriceService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketController::class.java)
|
||||
|
||||
/**
|
||||
* 获取市场价格(通过 Gamma API)
|
||||
* 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数
|
||||
* 获取市场价格
|
||||
* 使用 MarketPriceService 获取当前市场价格(支持多数据源降级)
|
||||
* 返回当前价格,前端接收后自行填充到 bestBid 字段
|
||||
*/
|
||||
@PostMapping("/price")
|
||||
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
|
||||
@@ -36,16 +40,16 @@ class MarketController(
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("获取市场价格失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
val outcomeIndex = request.outcomeIndex ?: 0
|
||||
val price = runBlocking {
|
||||
marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex)
|
||||
}
|
||||
|
||||
val response = MarketPriceResponse(
|
||||
marketId = request.marketId,
|
||||
currentPrice = price.toString()
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场价格异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -11,7 +11,7 @@ import java.util.*
|
||||
* 用于 Docker 健康检查和监控
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
@RequestMapping("/api/system/health")
|
||||
class HealthController {
|
||||
|
||||
@GetMapping
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.NotificationConfigService
|
||||
import com.wrbug.polymarketbot.service.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 消息推送配置控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
@RequestMapping("/api/system/notifications")
|
||||
class NotificationController(
|
||||
private val notificationConfigService: NotificationConfigService,
|
||||
private val telegramNotificationService: TelegramNotificationService,
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.ProxyConfigService
|
||||
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 代理配置控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/proxy-config")
|
||||
@RequestMapping("/api/system/proxy")
|
||||
class ProxyConfigController(
|
||||
private val proxyConfigService: ProxyConfigService,
|
||||
private val apiHealthCheckService: ApiHealthCheckService,
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.system.AddRpcNodeRequest
|
||||
import com.wrbug.polymarketbot.service.system.NodeCheckResult
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import com.wrbug.polymarketbot.service.system.UpdateRpcNodeRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* RPC 节点管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/system/rpc-nodes")
|
||||
class RpcNodeController(
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RpcNodeController::class.java)
|
||||
|
||||
/**
|
||||
* 获取所有节点列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getAllNodes(@RequestBody request: Map<String, Any>?): ResponseEntity<ApiResponse<List<RpcNodeConfigDto>>> {
|
||||
return try {
|
||||
val nodes = rpcNodeService.getAllNodes()
|
||||
val dtos = nodes.map { it.toDto() }
|
||||
ResponseEntity.ok(ApiResponse.success(dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 RPC 节点列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "获取节点列表失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
*/
|
||||
@PostMapping("/add")
|
||||
fun addNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity<ApiResponse<RpcNodeConfigDto>> {
|
||||
return try {
|
||||
if (request.providerType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空"))
|
||||
}
|
||||
if (request.name.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空"))
|
||||
}
|
||||
|
||||
val result = rpcNodeService.addNode(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { node ->
|
||||
ResponseEntity.ok(ApiResponse.success(node.toDto()))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("添加 RPC 节点失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "添加节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("添加 RPC 节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "添加节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
fun updateNode(@RequestBody request: UpdateRpcNodeRequest): ResponseEntity<ApiResponse<RpcNodeConfigDto>> {
|
||||
return try {
|
||||
val result = rpcNodeService.updateNode(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { node ->
|
||||
ResponseEntity.ok(ApiResponse.success(node.toDto()))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新 RPC 节点失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新 RPC 节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
fun deleteNode(@RequestBody request: DeleteRpcNodeRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = rpcNodeService.deleteNode(request.id)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除 RPC 节点失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "删除节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除 RPC 节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "删除节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点优先级
|
||||
*/
|
||||
@PostMapping("/update-priority")
|
||||
fun updatePriority(@RequestBody request: UpdatePriorityRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = rpcNodeService.updatePriority(request.id, request.priority)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新节点优先级失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新优先级失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点优先级异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "更新优先级失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点健康状态
|
||||
*/
|
||||
@PostMapping("/check-health")
|
||||
fun checkHealth(@RequestBody request: CheckHealthRequest): ResponseEntity<ApiResponse<Any>> {
|
||||
return try {
|
||||
if (request.id != null) {
|
||||
// 检查单个节点
|
||||
val result = rpcNodeService.checkNodeHealth(request.id)
|
||||
result.fold(
|
||||
onSuccess = { checkResult ->
|
||||
ResponseEntity.ok(ApiResponse.success(checkResult.toDto()))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查节点健康状态失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "检查节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// 批量检查所有节点
|
||||
val result = rpcNodeService.checkAllNodesHealth()
|
||||
result.fold(
|
||||
onSuccess = { checkResults ->
|
||||
val dtos = checkResults.mapValues { it.value.toDto() }
|
||||
ResponseEntity.ok(ApiResponse.success(dtos))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("批量检查节点健康状态失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "批量检查节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查节点健康状态异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
customMsg = "检查节点失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验节点(添加前)
|
||||
*/
|
||||
@PostMapping("/validate")
|
||||
fun validateNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity<ApiResponse<ValidateNodeResponse>> {
|
||||
return try {
|
||||
if (request.providerType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空"))
|
||||
}
|
||||
if (request.name.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空"))
|
||||
}
|
||||
|
||||
// 临时创建节点配置以进行验证(不保存到数据库)
|
||||
// 这里直接复用 addNode 的部分逻辑,但只进行校验
|
||||
val result = rpcNodeService.addNode(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { node ->
|
||||
// 添加成功后立即删除(这只是为了校验)
|
||||
rpcNodeService.deleteNode(node.id!!)
|
||||
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||
valid = true,
|
||||
message = "节点可用",
|
||||
responseTimeMs = node.responseTimeMs
|
||||
)))
|
||||
},
|
||||
onFailure = { e ->
|
||||
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||
valid = false,
|
||||
message = e.message ?: "节点验证失败",
|
||||
responseTimeMs = null
|
||||
)))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证节点异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||
valid = false,
|
||||
message = e.message ?: "节点验证失败",
|
||||
responseTimeMs = null
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 节点配置 DTO
|
||||
*/
|
||||
data class RpcNodeConfigDto(
|
||||
val id: Long?,
|
||||
val providerType: String,
|
||||
val name: String,
|
||||
val httpUrl: String,
|
||||
val wsUrl: String?,
|
||||
val apiKeyMasked: String?, // 脱敏后的 API Key
|
||||
val enabled: Boolean,
|
||||
val priority: Int,
|
||||
val lastCheckTime: Long?,
|
||||
val lastCheckStatus: String?,
|
||||
val responseTimeMs: Int?,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 节点检查结果 DTO
|
||||
*/
|
||||
data class NodeCheckResultDto(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val checkTime: Long,
|
||||
val responseTimeMs: Int?,
|
||||
val blockNumber: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证节点响应
|
||||
*/
|
||||
data class ValidateNodeResponse(
|
||||
val valid: Boolean,
|
||||
val message: String,
|
||||
val responseTimeMs: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* 删除节点请求
|
||||
*/
|
||||
data class DeleteRpcNodeRequest(
|
||||
val id: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新优先级请求
|
||||
*/
|
||||
data class UpdatePriorityRequest(
|
||||
val id: Long,
|
||||
val priority: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查健康状态请求
|
||||
*/
|
||||
data class CheckHealthRequest(
|
||||
val id: Long? = null // 如果为 null,则检查所有节点
|
||||
)
|
||||
|
||||
/**
|
||||
* 扩展函数:将 RpcNodeConfig 转换为 DTO
|
||||
*/
|
||||
private fun RpcNodeConfig.toDto(): RpcNodeConfigDto {
|
||||
return RpcNodeConfigDto(
|
||||
id = id,
|
||||
providerType = providerType,
|
||||
name = name,
|
||||
httpUrl = httpUrl,
|
||||
wsUrl = wsUrl,
|
||||
apiKeyMasked = apiKey?.let { "***" }, // 脱敏显示
|
||||
enabled = enabled,
|
||||
priority = priority,
|
||||
lastCheckTime = lastCheckTime,
|
||||
lastCheckStatus = lastCheckStatus,
|
||||
responseTimeMs = responseTimeMs,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展函数:将 NodeCheckResult 转换为 DTO
|
||||
*/
|
||||
private fun NodeCheckResult.toDto(): NodeCheckResultDto {
|
||||
return NodeCheckResultDto(
|
||||
status = status.name,
|
||||
message = message,
|
||||
checkTime = checkTime,
|
||||
responseTimeMs = responseTimeMs,
|
||||
blockNumber = blockNumber
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.SystemConfigService
|
||||
import com.wrbug.polymarketbot.service.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.SystemConfigService
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
package com.wrbug.polymarketbot.controller.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.UserService
|
||||
import com.wrbug.polymarketbot.service.system.UserService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*
|
||||
* 用户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequestMapping("/api/system/users")
|
||||
class UserController(
|
||||
private val userService: UserService,
|
||||
private val messageSource: MessageSource
|
||||
@@ -7,7 +7,8 @@ data class AccountImportRequest(
|
||||
val privateKey: String, // 私钥(前端加密后传输)
|
||||
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
||||
val accountName: String? = null,
|
||||
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
|
||||
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
|
||||
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,9 @@ data class SystemConfigDto(
|
||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
@@ -69,6 +73,7 @@ data class AccountDto(
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址
|
||||
val accountName: String?,
|
||||
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
|
||||
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
|
||||
val apiSecretConfigured: Boolean, // API Secret 是否已配置
|
||||
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
||||
@@ -192,14 +197,11 @@ data class LatestPriceRequest(
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场价格响应
|
||||
* 市场当前价格响应
|
||||
*/
|
||||
data class MarketPriceResponse(
|
||||
val marketId: String,
|
||||
val lastPrice: String?, // 最新成交价
|
||||
val bestBid: String?, // 最优买价(用于卖出参考)
|
||||
val bestAsk: String?, // 最优卖价(用于买入参考)
|
||||
val midpoint: String? // 中间价
|
||||
val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级)
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,3 +14,10 @@ data class CheckFirstUseResponse(
|
||||
val isFirstUse: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* WebSocket 票据响应
|
||||
*/
|
||||
data class WebSocketTicketResponse(
|
||||
val ticket: String
|
||||
)
|
||||
|
||||
|
||||
@@ -32,9 +32,11 @@ data class CopyTradingCreateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
||||
@@ -64,9 +66,11 @@ data class CopyTradingUpdateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null,
|
||||
val maxSpread: String? = null,
|
||||
val minOrderbookDepth: String? = null,
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
||||
@@ -133,9 +137,11 @@ data class CopyTradingDto(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String?,
|
||||
val maxSpread: String?,
|
||||
val minOrderbookDepth: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||
|
||||
@@ -22,7 +22,6 @@ data class TemplateCreateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
@@ -50,7 +49,6 @@ data class TemplateUpdateRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
@@ -85,7 +83,6 @@ data class TemplateCopyRequest(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
@@ -120,7 +117,6 @@ data class TemplateDto(
|
||||
// 过滤条件
|
||||
val minOrderDepth: String?,
|
||||
val maxSpread: String?,
|
||||
val minOrderbookDepth: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
val createdAt: Long,
|
||||
|
||||
@@ -40,6 +40,9 @@ data class Account(
|
||||
@Column(name = "is_enabled", nullable = false)
|
||||
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
|
||||
|
||||
@Column(name = "wallet_type", nullable = false, length = 20)
|
||||
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ data class CopyOrderTracking(
|
||||
@Column(name = "leader_buy_trade_id", nullable = false, length = 100)
|
||||
val leaderBuyTradeId: String, // Leader 买入交易ID
|
||||
|
||||
@Column(name = "leader_buy_quantity", nullable = true, precision = 20, scale = 8)
|
||||
val leaderBuyQuantity: BigDecimal? = null, // Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal, // 买入数量
|
||||
|
||||
@@ -53,6 +56,9 @@ data class CopyOrderTracking(
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: String = "filled", // filled, fully_matched, partially_matched
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ data class CopyTrading(
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
@@ -78,15 +78,19 @@ data class CopyTrading(
|
||||
@Column(name = "max_spread", precision = 20, scale = 8)
|
||||
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
|
||||
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
|
||||
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
// 最大仓位配置
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "max_position_count")
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
|
||||
// 新增配置字段
|
||||
@Column(name = "config_name", length = 255)
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
|
||||
@@ -20,7 +20,7 @@ data class CopyTradingTemplate(
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
@@ -66,9 +66,6 @@ data class CopyTradingTemplate(
|
||||
@Column(name = "max_spread", precision = 20, scale = 8)
|
||||
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
|
||||
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
|
||||
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 失败交易实体
|
||||
* 记录处理失败的交易信息
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "failed_trade")
|
||||
data class FailedTrade(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "leader_id", nullable = false)
|
||||
val leaderId: Long,
|
||||
|
||||
@Column(name = "leader_trade_id", nullable = false, length = 100)
|
||||
val leaderTradeId: String, // Leader 的交易ID
|
||||
|
||||
@Column(name = "trade_type", nullable = false, length = 10)
|
||||
val tradeType: String, // BUY 或 SELL
|
||||
|
||||
@Column(name = "copy_trading_id", nullable = false)
|
||||
val copyTradingId: Long,
|
||||
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long,
|
||||
|
||||
@Column(name = "market_id", nullable = false, length = 100)
|
||||
val marketId: String,
|
||||
|
||||
@Column(name = "side", nullable = false, length = 10)
|
||||
val side: String, // YES/NO
|
||||
|
||||
@Column(name = "price", nullable = false, length = 50)
|
||||
val price: String, // 价格(字符串格式)
|
||||
|
||||
@Column(name = "size", nullable = false, length = 50)
|
||||
val size: String, // 数量(字符串格式)
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
val errorMessage: String? = null, // 错误信息
|
||||
|
||||
@Column(name = "retry_count", nullable = false)
|
||||
val retryCount: Int = 0, // 重试次数
|
||||
|
||||
@Column(name = "failed_at", nullable = false)
|
||||
val failedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* Polygon RPC 节点配置实体
|
||||
* 用于存储用户配置的 RPC 节点信息
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "rpc_node_config")
|
||||
data class RpcNodeConfig(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "provider_type", nullable = false, length = 50)
|
||||
val providerType: String, // 服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC
|
||||
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
val name: String, // 节点名称
|
||||
|
||||
@Column(name = "http_url", nullable = false, length = 500)
|
||||
val httpUrl: String, // HTTP RPC URL
|
||||
|
||||
@Column(name = "ws_url", length = 500)
|
||||
val wsUrl: String? = null, // WebSocket URL (可选)
|
||||
|
||||
@Column(name = "api_key", length = 200)
|
||||
val apiKey: String? = null, // API Key (加密存储)
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
var enabled: Boolean = true, // 是否启用
|
||||
|
||||
@Column(name = "priority", nullable = false)
|
||||
var priority: Int = 0, // 优先级(数字越小优先级越高)
|
||||
|
||||
@Column(name = "last_check_time")
|
||||
var lastCheckTime: Long? = null, // 最后检查时间(毫秒时间戳)
|
||||
|
||||
@Column(name = "last_check_status", length = 20)
|
||||
var lastCheckStatus: String? = null, // 最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN
|
||||
|
||||
@Column(name = "response_time_ms")
|
||||
var responseTimeMs: Int? = null, // 最后一次响应时间(毫秒)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* RPC 节点健康状态枚举
|
||||
*/
|
||||
enum class NodeHealthStatus {
|
||||
HEALTHY, // 健康
|
||||
UNHEALTHY, // 不健康
|
||||
UNKNOWN // 未知
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 节点服务商类型枚举
|
||||
*/
|
||||
enum class RpcProviderType {
|
||||
ALCHEMY,
|
||||
INFURA,
|
||||
QUICKNODE,
|
||||
CHAINSTACK,
|
||||
GETBLOCK,
|
||||
CUSTOM,
|
||||
PUBLIC
|
||||
}
|
||||
@@ -41,6 +41,9 @@ data class SellMatchRecord(
|
||||
@Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val totalRealizedPnl: BigDecimal, // 总已实现盈亏
|
||||
|
||||
@Column(name = "price_updated", nullable = false)
|
||||
var priceUpdated: Boolean = false, // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
+11
@@ -50,5 +50,16 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
* 根据买入订单ID查询订单跟踪记录
|
||||
*/
|
||||
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询未发送通知的买入订单(用于轮询更新)
|
||||
*/
|
||||
fun findByNotificationSentFalse(): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询指定时间之前创建的订单(用于检查30秒后未成交的订单)
|
||||
*/
|
||||
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
|
||||
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.FailedTrade
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 失败交易Repository
|
||||
*/
|
||||
@Repository
|
||||
interface FailedTradeRepository : JpaRepository<FailedTrade, Long> {
|
||||
|
||||
/**
|
||||
* 根据Leader ID和交易ID查询
|
||||
*/
|
||||
fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade?
|
||||
|
||||
/**
|
||||
* 检查是否存在失败的交易
|
||||
*/
|
||||
fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface RpcNodeConfigRepository : JpaRepository<RpcNodeConfig, Long> {
|
||||
/**
|
||||
* 查询所有已启用的节点,按优先级排序(优先级数字越小越靠前)
|
||||
*/
|
||||
fun findAllByEnabledTrueOrderByPriorityAsc(): List<RpcNodeConfig>
|
||||
|
||||
/**
|
||||
* 查询指定 ID 的已启用节点
|
||||
*/
|
||||
fun findByIdAndEnabledTrue(id: Long): RpcNodeConfig?
|
||||
|
||||
/**
|
||||
* 查询所有节点,按优先级排序
|
||||
*/
|
||||
fun findAllByOrderByPriorityAsc(): List<RpcNodeConfig>
|
||||
}
|
||||
+6
@@ -24,5 +24,11 @@ interface SellMatchRecordRepository : JpaRepository<SellMatchRecord, Long> {
|
||||
* 根据Leader卖出交易ID查询记录
|
||||
*/
|
||||
fun findByLeaderSellTradeId(leaderSellTradeId: String): SellMatchRecord?
|
||||
|
||||
/**
|
||||
* 查询所有价格未更新的卖出记录
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
fun findByPriceUpdatedFalse(): List<SellMatchRecord>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.OrderbookResponse
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.lt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 跟单过滤条件检查服务
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingFilterService(
|
||||
private val clobService: PolymarketClobService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
|
||||
|
||||
/**
|
||||
* 检查过滤条件
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tokenId token ID(用于获取订单簿)
|
||||
* @param isBuyOrder 是否为买入订单(true=买入,false=卖出)
|
||||
* @return Pair<是否通过, 失败原因>
|
||||
*/
|
||||
suspend fun checkFilters(
|
||||
copyTrading: CopyTrading,
|
||||
tokenId: String,
|
||||
isBuyOrder: Boolean,
|
||||
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
|
||||
): Pair<Boolean, String> {
|
||||
// 1. 价格区间检查(如果配置了价格区间)
|
||||
if (tradePrice != null) {
|
||||
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
|
||||
if (!priceRangeCheck.first) {
|
||||
return priceRangeCheck
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 价格合理性检查(基础检查,无需配置)
|
||||
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败
|
||||
|
||||
// 3. 获取订单簿
|
||||
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
|
||||
if (!orderbookResult.isSuccess) {
|
||||
val error = orderbookResult.exceptionOrNull()
|
||||
return Pair(false, "获取订单簿失败: ${error?.message ?: "未知错误"}")
|
||||
}
|
||||
|
||||
val orderbook = orderbookResult.getOrNull()
|
||||
if (orderbook == null) {
|
||||
return Pair(false, "订单簿为空")
|
||||
}
|
||||
|
||||
// 4. 买一卖一价差过滤
|
||||
val spreadCheck = checkSpread(copyTrading, orderbook)
|
||||
if (!spreadCheck.first) {
|
||||
return spreadCheck
|
||||
}
|
||||
|
||||
// 5. 订单深度过滤
|
||||
val depthCheck = checkOrderDepth(copyTrading, orderbook, isBuyOrder)
|
||||
if (!depthCheck.first) {
|
||||
return depthCheck
|
||||
}
|
||||
|
||||
// 6. 最小订单簿深度过滤(可选)
|
||||
val orderbookDepthCheck = checkOrderbookDepth(copyTrading, orderbook, isBuyOrder)
|
||||
if (!orderbookDepthCheck.first) {
|
||||
return orderbookDepthCheck
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查价格区间
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tradePrice Leader 交易价格
|
||||
* @return Pair<是否通过, 失败原因>
|
||||
*/
|
||||
private fun checkPriceRange(
|
||||
copyTrading: CopyTrading,
|
||||
tradePrice: BigDecimal
|
||||
): Pair<Boolean, String> {
|
||||
// 如果未配置价格区间,直接通过
|
||||
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
// 检查最低价格
|
||||
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
||||
return Pair(false, "价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
||||
}
|
||||
|
||||
// 检查最高价格
|
||||
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
||||
return Pair(false, "价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查买一卖一价差
|
||||
* bestBid: 买盘中的最高价格(最大值)
|
||||
* bestAsk: 卖盘中的最低价格(最小值)
|
||||
*/
|
||||
private fun checkSpread(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse
|
||||
): Pair<Boolean, String> {
|
||||
// 如果未启用价差过滤,直接通过
|
||||
if (copyTrading.maxSpread == null) {
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
|
||||
val bestBid = orderbook.bids
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.maxOrNull()
|
||||
|
||||
// 获取卖盘中的最低价格(bestAsk = asks 中的最小值)
|
||||
val bestAsk = orderbook.asks
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.minOrNull()
|
||||
|
||||
if (bestBid == null || bestAsk == null) {
|
||||
return Pair(false, "订单簿缺少买一或卖一价格")
|
||||
}
|
||||
|
||||
// 计算价差(绝对价格)
|
||||
val spread = bestAsk.subtract(bestBid)
|
||||
|
||||
if (spread.gt(copyTrading.maxSpread)) {
|
||||
return Pair(false, "价差过大: $spread > ${copyTrading.maxSpread}")
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查订单深度
|
||||
*/
|
||||
private fun checkOrderDepth(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse,
|
||||
isBuyOrder: Boolean
|
||||
): Pair<Boolean, String> {
|
||||
// 如果未启用订单深度过滤,直接通过
|
||||
if (copyTrading.minOrderDepth == null) {
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
// 对于买入订单,检查卖盘(asks)深度
|
||||
// 对于卖出订单,检查买盘(bids)深度
|
||||
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
|
||||
|
||||
// 计算总深度(累计订单金额)
|
||||
var totalDepth = BigDecimal.ZERO
|
||||
for (order in orders) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
totalDepth = totalDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
||||
return Pair(false, "订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}")
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查最小订单簿深度(前 N 档深度)
|
||||
*/
|
||||
private fun checkOrderbookDepth(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse,
|
||||
isBuyOrder: Boolean
|
||||
): Pair<Boolean, String> {
|
||||
// 如果未启用最小订单簿深度过滤,直接通过
|
||||
if (copyTrading.minOrderbookDepth == null) {
|
||||
return Pair(true, "")
|
||||
}
|
||||
|
||||
// 对于买入订单,检查卖盘(asks)前 3 档深度
|
||||
// 对于卖出订单,检查买盘(bids)前 3 档深度
|
||||
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
|
||||
val topNOrders = orders.take(3) // 前 3 档
|
||||
|
||||
// 计算前 N 档总深度
|
||||
var totalDepth = BigDecimal.ZERO
|
||||
for (order in topNOrders) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
totalDepth = totalDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderbookDepth)) {
|
||||
return Pair(false, "订单簿深度不足: $totalDepth < ${copyTrading.minOrderbookDepth}")
|
||||
}
|
||||
|
||||
return Pair(true, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* 跟单监听服务(主服务)
|
||||
* 管理所有Leader的交易监听(使用轮询方式)
|
||||
* 注意:WebSocket 需要认证才能订阅其他用户的交易,因此只使用轮询方式
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingMonitorService(
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val pollingService: CopyTradingPollingService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/**
|
||||
* 系统启动时初始化监听
|
||||
*/
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
scope.launch {
|
||||
try {
|
||||
startMonitoring()
|
||||
} catch (e: Exception) {
|
||||
logger.error("启动跟单监听失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统关闭时清理资源
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
// 只使用轮询,不使用WebSocket
|
||||
pollingService.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
*/
|
||||
suspend fun startMonitoring() {
|
||||
// 1. 获取所有启用的跟单关系
|
||||
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
|
||||
|
||||
if (enabledCopyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取所有需要监听的Leader(去重)
|
||||
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
|
||||
val leaders = leaderIds.mapNotNull { leaderId ->
|
||||
leaderRepository.findById(leaderId).orElse(null)
|
||||
}
|
||||
|
||||
|
||||
// 3. 启动轮询监听(使用 /activity 接口,不需要认证)
|
||||
// 注意:WebSocket 需要认证才能订阅其他用户的交易,因此禁用WebSocket,只使用轮询
|
||||
pollingService.start(leaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听(当创建新的跟单关系时调用)
|
||||
*/
|
||||
suspend fun addLeaderMonitoring(leaderId: Long) {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return
|
||||
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只使用轮询,不使用WebSocket(需要认证)
|
||||
pollingService.addLeader(leader)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听(当删除跟单关系时调用)
|
||||
*/
|
||||
suspend fun removeLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只使用轮询,不使用WebSocket
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启动监听(当跟单关系状态改变时调用)
|
||||
*/
|
||||
suspend fun restartMonitoring() {
|
||||
// 只使用轮询,不使用WebSocket
|
||||
pollingService.stop()
|
||||
delay(1000) // 等待1秒
|
||||
startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 频率限制服务(使用内存缓存,全局限制)
|
||||
*/
|
||||
@Service
|
||||
class RateLimitService {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
|
||||
|
||||
@Value("\${rate-limit.reset-password.max-attempts:3}")
|
||||
private var maxAttempts: Int = 3
|
||||
|
||||
@Value("\${rate-limit.reset-password.window-seconds:60}")
|
||||
private var windowSeconds: Long = 60
|
||||
|
||||
// 全局尝试记录列表(时间戳),所有请求共享
|
||||
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
|
||||
|
||||
/**
|
||||
* 检查重置密码频率限制(全局限制,不按IP)
|
||||
* @return Result,如果超过限制则返回失败
|
||||
*/
|
||||
fun checkResetPasswordRateLimit(): Result<Unit> {
|
||||
val now = System.currentTimeMillis()
|
||||
val windowStart = now - (windowSeconds * 1000)
|
||||
|
||||
// 获取当前尝试记录列表
|
||||
val attempts = resetPasswordAttempts.get()
|
||||
|
||||
// 清理过期记录(超过时间窗口的记录)
|
||||
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
|
||||
|
||||
// 检查是否超过限制
|
||||
if (validAttempts.size >= maxAttempts) {
|
||||
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts")
|
||||
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试"))
|
||||
}
|
||||
|
||||
// 记录本次尝试
|
||||
validAttempts.add(now)
|
||||
resetPasswordAttempts.set(validAttempts)
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-12
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
@@ -8,6 +8,14 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -27,9 +35,10 @@ class AccountService(
|
||||
private val apiKeyService: PolymarketApiKeyService,
|
||||
private val orderPushService: OrderPushService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
|
||||
private val relayClientService: RelayClientService
|
||||
private val relayClientService: RelayClientService,
|
||||
private val jsonUtils: JsonUtils
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountService::class.java)
|
||||
@@ -90,8 +99,9 @@ class AccountService(
|
||||
}
|
||||
|
||||
// 5. 获取代理地址(必须成功,否则导入失败)
|
||||
// 根据用户选择的钱包类型计算代理地址
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
@@ -141,6 +151,7 @@ class AccountService(
|
||||
accountName = accountName,
|
||||
isDefault = false, // 不再支持默认账户
|
||||
isEnabled = request.isEnabled,
|
||||
walletType = request.walletType, // 保存钱包类型
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
@@ -354,6 +365,7 @@ class AccountService(
|
||||
proxyAddress = account.proxyAddress,
|
||||
accountName = account.accountName,
|
||||
isEnabled = account.isEnabled,
|
||||
walletType = account.walletType,
|
||||
apiKeyConfigured = account.apiKey != null,
|
||||
apiSecretConfigured = account.apiSecret != null,
|
||||
apiPassphraseConfigured = account.apiPassphrase != null,
|
||||
@@ -919,6 +931,8 @@ class AccountService(
|
||||
marketId = request.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = request.side,
|
||||
price = sellPrice, // 直接传递卖出价格
|
||||
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
clobApi = clobApi,
|
||||
@@ -1070,7 +1084,7 @@ class AccountService(
|
||||
/**
|
||||
* 从订单表获取最优价(用于市价单)
|
||||
* 支持多元市场(二元、三元及以上)
|
||||
* 委托给 PolymarketClobService.getOptimalPrice 方法
|
||||
* 委托给 com.wrbug.polymarketbot.service.common.PolymarketClobService.getOptimalPrice 方法
|
||||
*
|
||||
* @param tokenId token ID(通过 marketId 和 outcomeIndex 计算得出)
|
||||
* @param isSellOrder 是否为卖出订单(true: 卖单,需要 bestBid;false: 买单,需要 bestAsk)
|
||||
@@ -1111,7 +1125,7 @@ class AccountService(
|
||||
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
|
||||
// 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格
|
||||
if (outcomeIndex != null && outcomeIndex > 0) {
|
||||
val outcomes = JsonUtils.parseStringArray(market.outcomes)
|
||||
val outcomes = jsonUtils.parseStringArray(market.outcomes)
|
||||
// 只对二元市场进行价格转换
|
||||
if (outcomes.size == 2) {
|
||||
// 保存原始第一个 outcome 的价格
|
||||
@@ -1143,13 +1157,13 @@ class AccountService(
|
||||
null
|
||||
}
|
||||
|
||||
// 优先使用 lastPrice(最近成交价),如果没有则使用 bestBid,最后使用 midpoint
|
||||
val currentPrice = lastPrice ?: bestBid ?: midpoint ?: "0"
|
||||
|
||||
Result.success(
|
||||
MarketPriceResponse(
|
||||
marketId = marketId,
|
||||
lastPrice = lastPrice,
|
||||
bestBid = bestBid,
|
||||
bestAsk = bestAsk,
|
||||
midpoint = midpoint
|
||||
currentPrice = currentPrice
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -1167,7 +1181,7 @@ class AccountService(
|
||||
/**
|
||||
* 获取可赎回仓位统计
|
||||
*/
|
||||
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<com.wrbug.polymarketbot.dto.RedeemablePositionsSummary> {
|
||||
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<RedeemablePositionsSummary> {
|
||||
return try {
|
||||
val positionsResult = getAllPositions()
|
||||
positionsResult.fold(
|
||||
@@ -1223,7 +1237,7 @@ class AccountService(
|
||||
* 赎回仓位
|
||||
* 支持多账户、多仓位赎回(自动按账户和市场分组)
|
||||
*/
|
||||
suspend fun redeemPositions(request: com.wrbug.polymarketbot.dto.PositionRedeemRequest): Result<com.wrbug.polymarketbot.dto.PositionRedeemResponse> {
|
||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||
return try {
|
||||
// 检查 Builder API Key 是否已配置
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
+70
-31
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.AccountPositionDto
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
@@ -18,6 +18,10 @@ import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.context.i18n.LocaleContextHolder
|
||||
import com.wrbug.polymarketbot.service.system.SystemConfigService
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -39,7 +43,8 @@ class PositionCheckService(
|
||||
private val relayClientService: RelayClientService,
|
||||
private val telegramNotificationService: TelegramNotificationService?,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val messageSource: MessageSource
|
||||
private val messageSource: MessageSource,
|
||||
private val marketPriceService: MarketPriceService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
||||
@@ -186,7 +191,7 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 逻辑1:处理待赎回仓位
|
||||
* 按照以下逻辑处理:
|
||||
https://clob.polymarket.com * 按照以下逻辑处理:
|
||||
* 1. 无待赎回仓位:跳过
|
||||
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||
@@ -366,17 +371,51 @@ class PositionCheckService(
|
||||
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
|
||||
|
||||
if (position == null) {
|
||||
// 仓位不存在,更新所有订单状态为已卖出
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
|
||||
// 仓位不存在,检查订单创建时间
|
||||
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
|
||||
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
|
||||
val now = System.currentTimeMillis()
|
||||
val ordersToMarkAsSold = orders.filter { order ->
|
||||
val orderAge = now - order.createdAt
|
||||
orderAge > 120000 // 2分钟 = 120000毫秒
|
||||
}
|
||||
|
||||
if (ordersToMarkAsSold.isNotEmpty()) {
|
||||
// 有订单创建时间超过2分钟,认为仓位已被出售
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
|
||||
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
|
||||
} else {
|
||||
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
|
||||
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
|
||||
}
|
||||
} else {
|
||||
// 有仓位,按订单下单顺序(FIFO)更新状态
|
||||
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
|
||||
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
|
||||
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
// 计算逻辑:
|
||||
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
|
||||
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
|
||||
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
|
||||
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
val positionQuantity = position.quantity.toSafeBigDecimal()
|
||||
|
||||
// 计算总订单数量
|
||||
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
|
||||
sum.add(order.remainingQuantity.toSafeBigDecimal())
|
||||
}
|
||||
|
||||
// 计算已成交数量
|
||||
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
|
||||
|
||||
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
|
||||
if (soldQuantity <= BigDecimal.ZERO) {
|
||||
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
|
||||
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
|
||||
copyTrading.id, marketId, outcomeIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,25 +426,13 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 获取当前市场最新价(用于更新订单卖出价)
|
||||
* 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价)
|
||||
* 委托给 MarketPriceService 处理
|
||||
*/
|
||||
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||
return try {
|
||||
val priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
|
||||
val marketPrice = priceResult.getOrNull()
|
||||
if (marketPrice != null) {
|
||||
// 优先使用 bestBid(最优买价,用于卖出参考),如果没有则使用 midpoint
|
||||
val priceStr = marketPrice.bestBid ?: marketPrice.midpoint ?: marketPrice.lastPrice
|
||||
priceStr?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场最新价失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
return marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 在仓位赎回成功后,更新订单状态为已卖出
|
||||
* 使用卖出逻辑更新所有订单状态(未卖出订单的)
|
||||
@@ -492,7 +519,8 @@ class PositionCheckService(
|
||||
outcomeIndex = outcomeIndex,
|
||||
totalMatchedQuantity = totalMatchedQuantity,
|
||||
sellPrice = sellPrice,
|
||||
totalRealizedPnl = totalRealizedPnl
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
|
||||
)
|
||||
|
||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||
@@ -512,12 +540,22 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 按 FIFO 顺序更新订单状态为已卖出
|
||||
* 仓位数量小于订单数量总和时,按订单下单顺序更新
|
||||
* 同时创建卖出记录和匹配明细,用于统计
|
||||
* @param orders 订单列表(已按创建时间排序,FIFO)
|
||||
* @param soldQuantity 已成交数量(总订单数量 - 仓位数量)
|
||||
* @param sellPrice 卖出价格
|
||||
* @param copyTradingId 跟单配置ID
|
||||
* @param marketId 市场ID
|
||||
* @param outcomeIndex 结果索引
|
||||
*
|
||||
* 逻辑说明:
|
||||
* 1. 按订单创建时间顺序(FIFO)处理
|
||||
* 2. 如果订单剩余数量 <= 已成交数量,订单完全成交
|
||||
* 3. 如果订单剩余数量 > 已成交数量,订单部分成交
|
||||
* 4. 同时创建卖出记录和匹配明细,用于统计
|
||||
*/
|
||||
private suspend fun updateOrdersAsSoldByFIFO(
|
||||
orders: List<CopyOrderTracking>,
|
||||
availableQuantity: BigDecimal,
|
||||
soldQuantity: BigDecimal,
|
||||
sellPrice: BigDecimal,
|
||||
copyTradingId: Long,
|
||||
marketId: String,
|
||||
@@ -529,7 +567,7 @@ class PositionCheckService(
|
||||
|
||||
try {
|
||||
// 订单已经按 createdAt ASC 排序(FIFO)
|
||||
var remaining = availableQuantity
|
||||
var remaining = soldQuantity
|
||||
var totalMatchedQuantity = BigDecimal.ZERO
|
||||
var totalRealizedPnl = BigDecimal.ZERO
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
@@ -596,7 +634,8 @@ class PositionCheckService(
|
||||
outcomeIndex = outcomeIndex,
|
||||
totalMatchedQuantity = totalMatchedQuantity,
|
||||
sellPrice = sellPrice,
|
||||
totalRealizedPnl = totalRealizedPnl
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
|
||||
)
|
||||
|
||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.PositionListResponse
|
||||
import jakarta.annotation.PostConstruct
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.dto.AccountPositionDto
|
||||
import com.wrbug.polymarketbot.dto.PositionListResponse
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessageType
|
||||
import com.wrbug.polymarketbot.dto.getPositionKey
|
||||
@@ -155,7 +156,7 @@ class PositionPushService(
|
||||
* 处理仓位更新事件
|
||||
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
|
||||
*/
|
||||
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
|
||||
private fun handlePositionUpdate(positions: PositionListResponse) {
|
||||
// 更新快照
|
||||
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
|
||||
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
|
||||
+74
-75
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.announcement
|
||||
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.GitHubCommentResponse
|
||||
import com.wrbug.polymarketbot.dto.AnnouncementDto
|
||||
import com.wrbug.polymarketbot.dto.AnnouncementListResponse
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
@@ -25,30 +26,30 @@ class AnnouncementService(
|
||||
@Value("\${github.announcement.issue.number:1}")
|
||||
private val issueNumber: Int
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AnnouncementService::class.java)
|
||||
|
||||
|
||||
// GitHub API 客户端(懒加载)
|
||||
private val githubApi: GitHubApi by lazy {
|
||||
retrofitFactory.createGitHubApi()
|
||||
}
|
||||
|
||||
|
||||
// 需要排除的 Issue ID(从 issue_url 中提取)
|
||||
private val excludedIssueId = "3703128976"
|
||||
|
||||
|
||||
// 缓存数据(1分钟有效期)
|
||||
private data class CachedData<T>(
|
||||
val data: T,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
|
||||
private var cachedList: CachedData<AnnouncementListResponse>? = null
|
||||
private var cachedAssignees: CachedData<List<String>>? = null
|
||||
private var cachedComments: CachedData<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>>? = null
|
||||
|
||||
|
||||
// 缓存有效期:10分钟(毫秒)
|
||||
private val cacheExpiryTime = 10 * 60 * 1000L
|
||||
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
*/
|
||||
@@ -57,7 +58,7 @@ class AnnouncementService(
|
||||
val now = System.currentTimeMillis()
|
||||
return (now - cached.timestamp) < cacheExpiryTime
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查是否被限流
|
||||
*/
|
||||
@@ -70,7 +71,7 @@ class AnnouncementService(
|
||||
val remaining = response.headers()["X-RateLimit-Remaining"]
|
||||
return remaining == "0"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取 Issue 的 assignees 列表(通过 API 获取,带缓存)
|
||||
* @return Pair<assignees列表, 是否使用了缓存>
|
||||
@@ -81,14 +82,14 @@ class AnnouncementService(
|
||||
logger.debug("使用缓存的 assignees")
|
||||
return Pair(cachedAssignees!!.data, true)
|
||||
}
|
||||
|
||||
|
||||
return try {
|
||||
val response = githubApi.getIssue(
|
||||
owner = repoOwner,
|
||||
repo = repoName,
|
||||
issueNumber = issueNumber
|
||||
)
|
||||
|
||||
|
||||
// 如果被限流,使用缓存数据,不更新缓存
|
||||
if (isRateLimited(response)) {
|
||||
logger.warn("GitHub API 被限流,使用缓存的 assignees(不更新缓存)")
|
||||
@@ -98,14 +99,14 @@ class AnnouncementService(
|
||||
// 如果没有缓存,使用默认值
|
||||
return Pair(listOf("WrBug"), false)
|
||||
}
|
||||
|
||||
|
||||
val assignees = if (response.isSuccessful && response.body() != null) {
|
||||
response.body()!!.assignees.map { it.login }
|
||||
} else {
|
||||
logger.warn("获取 Issue assignees 失败,使用默认值: code=${response.code()}")
|
||||
listOf("WrBug") // 默认值
|
||||
}
|
||||
|
||||
|
||||
// 更新缓存
|
||||
cachedAssignees = CachedData(assignees, System.currentTimeMillis())
|
||||
Pair(assignees, false) // 返回新数据,标记为未使用缓存
|
||||
@@ -119,24 +120,24 @@ class AnnouncementService(
|
||||
Pair(listOf("WrBug"), false) // 默认值
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取 Issue 评论列表(带缓存)
|
||||
* @return Pair<评论列表, 是否使用了缓存>
|
||||
*/
|
||||
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<com.wrbug.polymarketbot.api.GitHubCommentResponse>, Boolean> {
|
||||
private suspend fun getIssueComments(forceRefresh: Boolean = false): Pair<List<GitHubCommentResponse>, Boolean> {
|
||||
// 检查缓存
|
||||
if (!forceRefresh && isCacheValid(cachedComments)) {
|
||||
logger.debug("使用缓存的评论列表")
|
||||
return Pair(cachedComments!!.data, true)
|
||||
}
|
||||
|
||||
|
||||
val response = githubApi.getIssueComments(
|
||||
owner = repoOwner,
|
||||
repo = repoName,
|
||||
issueNumber = issueNumber
|
||||
)
|
||||
|
||||
|
||||
// 如果被限流,使用缓存数据,不更新缓存
|
||||
if (isRateLimited(response)) {
|
||||
logger.warn("GitHub API 被限流,使用缓存的评论列表(不更新缓存)")
|
||||
@@ -146,7 +147,7 @@ class AnnouncementService(
|
||||
// 如果没有缓存,抛出异常
|
||||
throw Exception("获取公告列表失败: GitHub API 被限流,且无缓存数据")
|
||||
}
|
||||
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.error("获取 GitHub Issue 评论失败: code=${response.code()}, message=${response.message()}")
|
||||
// 如果缓存存在,使用缓存
|
||||
@@ -156,14 +157,14 @@ class AnnouncementService(
|
||||
}
|
||||
throw Exception("获取公告列表失败: HTTP ${response.code()}")
|
||||
}
|
||||
|
||||
|
||||
val comments = response.body()!!
|
||||
|
||||
|
||||
// 更新缓存
|
||||
cachedComments = CachedData(comments, System.currentTimeMillis())
|
||||
return Pair(comments, false) // 返回新数据,标记为未使用缓存
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取公告列表(最近10条)
|
||||
* @param forceRefresh 是否强制刷新缓存
|
||||
@@ -174,12 +175,12 @@ class AnnouncementService(
|
||||
logger.debug("使用缓存的公告列表")
|
||||
return Result.success(cachedList!!.data)
|
||||
}
|
||||
|
||||
|
||||
return try {
|
||||
// 强制刷新时,先尝试获取新数据
|
||||
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
|
||||
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
|
||||
|
||||
|
||||
// 如果强制刷新时使用了缓存(被限流),直接返回缓存数据,不更新缓存
|
||||
if (forceRefresh && (assigneesFromCache || commentsFromCache)) {
|
||||
logger.warn("强制刷新时被限流,返回缓存的公告列表(不更新缓存)")
|
||||
@@ -187,48 +188,47 @@ class AnnouncementService(
|
||||
return Result.success(cachedList!!.data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 筛选条件:
|
||||
// 1. assignees 发布的评论
|
||||
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
|
||||
val filteredComments = comments
|
||||
.filter { comment ->
|
||||
// 检查是否为 assignee
|
||||
val isAssignee = assigneeList.contains(comment.user.login)
|
||||
|
||||
// 检查是否应该排除(从 issue_url 中提取 issue ID)
|
||||
val shouldExclude = comment.issue_url?.let { issueUrl ->
|
||||
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
|
||||
// 提取最后的数字
|
||||
val issueId = issueUrl.split("/").lastOrNull()
|
||||
issueId == excludedIssueId
|
||||
} ?: false
|
||||
|
||||
isAssignee && !shouldExclude
|
||||
}
|
||||
val filteredComments = comments.filter { comment ->
|
||||
// 检查是否为 assignee
|
||||
val isAssignee = assigneeList.contains(comment.user.login)
|
||||
|
||||
// 检查是否应该排除(从 issue_url 中提取 issue ID)
|
||||
val shouldExclude = comment.issue_url?.let { issueUrl ->
|
||||
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
|
||||
// 提取最后的数字
|
||||
val issueId = issueUrl.split("/").lastOrNull()
|
||||
issueId == excludedIssueId
|
||||
} ?: false
|
||||
|
||||
isAssignee && !shouldExclude
|
||||
}
|
||||
.sortedByDescending { comment ->
|
||||
parseGitHubTime(comment.created_at)
|
||||
}
|
||||
|
||||
|
||||
val total = filteredComments.size
|
||||
val hasMore = total > 10
|
||||
|
||||
|
||||
// 取前10条
|
||||
val latest10 = filteredComments.take(10).map { comment ->
|
||||
toAnnouncementDto(comment)
|
||||
}
|
||||
|
||||
|
||||
val result = AnnouncementListResponse(
|
||||
list = latest10,
|
||||
hasMore = hasMore,
|
||||
total = total
|
||||
)
|
||||
|
||||
|
||||
// 只有在数据正常返回时才更新缓存(不是从缓存获取的)
|
||||
if (!assigneesFromCache && !commentsFromCache) {
|
||||
cachedList = CachedData(result, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
|
||||
Result.success(result)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取公告列表异常: ${e.message}", e)
|
||||
@@ -240,7 +240,7 @@ class AnnouncementService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取公告详情
|
||||
* @param id 评论ID,如果为 null 则返回最新一条
|
||||
@@ -249,19 +249,18 @@ class AnnouncementService(
|
||||
suspend fun getAnnouncementDetail(id: Long?, forceRefresh: Boolean = false): Result<AnnouncementDto> {
|
||||
return try {
|
||||
// 获取 assignees
|
||||
val (assigneeList, assigneesFromCache) = getAssignees(forceRefresh)
|
||||
|
||||
val (assigneeList, _) = getAssignees(forceRefresh)
|
||||
|
||||
// 获取评论列表
|
||||
val (comments, commentsFromCache) = getIssueComments(forceRefresh)
|
||||
|
||||
val (comments, _) = getIssueComments(forceRefresh)
|
||||
|
||||
// 筛选条件:
|
||||
// 1. assignees 发布的评论
|
||||
// 2. 排除 issueNumber 为 3703128976 的评论(从 issue_url 中提取)
|
||||
val filteredComments = comments
|
||||
.filter { comment ->
|
||||
val filteredComments = comments.filter { comment ->
|
||||
// 检查是否为 assignee
|
||||
val isAssignee = assigneeList.contains(comment.user.login)
|
||||
|
||||
|
||||
// 检查是否应该排除(从 issue_url 中提取 issue ID)
|
||||
val shouldExclude = comment.issue_url?.let { issueUrl ->
|
||||
// issue_url 格式:https://api.github.com/repos/owner/repo/issues/3703128976
|
||||
@@ -269,37 +268,37 @@ class AnnouncementService(
|
||||
val issueId = issueUrl.split("/").lastOrNull()
|
||||
issueId == excludedIssueId
|
||||
} ?: false
|
||||
|
||||
|
||||
isAssignee && !shouldExclude
|
||||
}
|
||||
.sortedByDescending { comment ->
|
||||
parseGitHubTime(comment.created_at)
|
||||
}
|
||||
|
||||
|
||||
val targetComment = if (id != null) {
|
||||
filteredComments.find { it.id == id }
|
||||
} else {
|
||||
filteredComments.firstOrNull()
|
||||
}
|
||||
|
||||
|
||||
if (targetComment == null) {
|
||||
return Result.failure(IllegalArgumentException("公告不存在"))
|
||||
}
|
||||
|
||||
|
||||
Result.success(toAnnouncementDto(targetComment))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取公告详情异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将 GitHub 评论转换为 AnnouncementDto
|
||||
*/
|
||||
private fun toAnnouncementDto(comment: com.wrbug.polymarketbot.api.GitHubCommentResponse): AnnouncementDto {
|
||||
private fun toAnnouncementDto(comment: GitHubCommentResponse): AnnouncementDto {
|
||||
// 提取标题(第一行,移除 Markdown 格式)
|
||||
val title = extractTitle(comment.body)
|
||||
|
||||
|
||||
// 转换 reactions 数据
|
||||
val reactions = comment.reactions?.let { r ->
|
||||
com.wrbug.polymarketbot.dto.ReactionsDto(
|
||||
@@ -314,7 +313,7 @@ class AnnouncementService(
|
||||
total = r.total_count
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return AnnouncementDto(
|
||||
id = comment.id,
|
||||
title = title,
|
||||
@@ -326,7 +325,7 @@ class AnnouncementService(
|
||||
reactions = reactions
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从评论内容中提取标题(第一行,移除 Markdown 格式)
|
||||
* 支持的 Markdown 格式:
|
||||
@@ -342,49 +341,49 @@ class AnnouncementService(
|
||||
if (body.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// 获取第一行
|
||||
val firstLine = body.lines().firstOrNull()?.trim() ?: ""
|
||||
if (firstLine.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// 移除 Markdown 格式
|
||||
var title = firstLine
|
||||
|
||||
|
||||
// 移除标题标记(# ## ### 等)
|
||||
title = title.replace(Regex("^#{1,6}\\s+"), "")
|
||||
|
||||
|
||||
// 移除粗体标记(**text** 或 __text__)
|
||||
title = title.replace(Regex("\\*\\*([^*]+)\\*\\*"), "$1")
|
||||
title = title.replace(Regex("__([^_]+)__"), "$1")
|
||||
|
||||
|
||||
// 移除斜体标记(*text* 或 _text_)
|
||||
title = title.replace(Regex("(?<!\\*)\\*([^*]+)\\*(?!\\*)"), "$1")
|
||||
title = title.replace(Regex("(?<!_)_([^_]+)_(?!_)"), "$1")
|
||||
|
||||
|
||||
// 移除代码标记(`code`)
|
||||
title = title.replace(Regex("`([^`]+)`"), "$1")
|
||||
|
||||
|
||||
// 移除链接标记([text](url))
|
||||
title = title.replace(Regex("\\[([^\\]]+)\\]\\([^\\)]+\\)"), "$1")
|
||||
|
||||
|
||||
// 移除图片标记()
|
||||
title = title.replace(Regex("!\\[([^\\]]*)\\]\\([^\\)]+\\)"), "$1")
|
||||
|
||||
|
||||
// 移除删除线标记(~~text~~)
|
||||
title = title.replace(Regex("~~([^~]+)~~"), "$1")
|
||||
|
||||
|
||||
// 移除引用标记(> text)
|
||||
title = title.replace(Regex("^>\\s+"), "")
|
||||
|
||||
|
||||
// 移除列表标记(- * + 1. 等)
|
||||
title = title.replace(Regex("^[-*+]\\s+"), "")
|
||||
title = title.replace(Regex("^\\d+\\.\\s+"), "")
|
||||
|
||||
|
||||
return title.trim()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析 GitHub 时间格式(ISO 8601)为时间戳(毫秒)
|
||||
* GitHub API 返回的时间格式:2025-12-07T14:30:00Z
|
||||
+33
-10
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.auth
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CheckFirstUseResponse
|
||||
import com.wrbug.polymarketbot.dto.LoginResponse
|
||||
@@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import com.wrbug.polymarketbot.service.common.RateLimitService
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@@ -30,22 +31,44 @@ class AuthService(
|
||||
private lateinit var resetPasswordKey: String
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* 登录(带IP限速保护)
|
||||
*/
|
||||
fun login(username: String, password: String): Result<LoginResponse> {
|
||||
fun login(username: String, password: String, ipAddress: String): Result<LoginResponse> {
|
||||
return try {
|
||||
// 检查登录频率限制
|
||||
rateLimitService.checkLoginRateLimit(ipAddress).fold(
|
||||
onSuccess = { },
|
||||
onFailure = { e ->
|
||||
return Result.failure(IllegalStateException(e.message ?: "登录频率限制"))
|
||||
}
|
||||
)
|
||||
|
||||
val user = userRepository.findByUsername(username)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
|
||||
|
||||
// 验证密码
|
||||
if (!passwordEncoder.matches(password, user.password)) {
|
||||
logger.warn("登录失败:密码错误,username=$username")
|
||||
if (user == null) {
|
||||
// 记录失败尝试
|
||||
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
|
||||
if (lockoutMsg != null) {
|
||||
return Result.failure(IllegalStateException(lockoutMsg))
|
||||
}
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
|
||||
}
|
||||
|
||||
|
||||
// 验证密码
|
||||
if (!passwordEncoder.matches(password, user.password)) {
|
||||
// 记录失败尝试
|
||||
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
|
||||
if (lockoutMsg != null) {
|
||||
return Result.failure(IllegalStateException(lockoutMsg))
|
||||
}
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
|
||||
}
|
||||
|
||||
// 登录成功,清除失败记录
|
||||
rateLimitService.clearLoginFailures(ipAddress)
|
||||
|
||||
// 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效)
|
||||
val token = jwtUtils.generateToken(username, user.tokenVersion)
|
||||
|
||||
|
||||
logger.info("用户登录成功:username=$username")
|
||||
Result.success(LoginResponse(token = token))
|
||||
} catch (e: Exception) {
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.wrbug.polymarketbot.service.auth
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* WebSocket 票据服务
|
||||
* 用于生成短期有效的一次性票据,避免在 WebSocket URL 中暴露 JWT
|
||||
*/
|
||||
@Service
|
||||
class WebSocketTicketService {
|
||||
|
||||
companion object {
|
||||
// 票据有效期(30秒)
|
||||
private const val TICKET_VALIDITY_MS = 30_000L
|
||||
|
||||
// 票据长度(32字节 = 64个十六进制字符)
|
||||
private const val TICKET_LENGTH = 32
|
||||
}
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
// 存储票据:ticket -> TicketInfo
|
||||
private val tickets = ConcurrentHashMap<String, TicketInfo>()
|
||||
|
||||
/**
|
||||
* 票据信息
|
||||
*/
|
||||
data class TicketInfo(
|
||||
val username: String,
|
||||
val createdAt: Long,
|
||||
val expiresAt: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 为用户生成 WebSocket 连接票据
|
||||
* @param username 用户名
|
||||
* @return 一次性票据
|
||||
*/
|
||||
fun generateTicket(username: String): String {
|
||||
// 清理过期票据
|
||||
cleanupExpiredTickets()
|
||||
|
||||
// 生成随机票据
|
||||
val bytes = ByteArray(TICKET_LENGTH)
|
||||
secureRandom.nextBytes(bytes)
|
||||
val ticket = bytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
tickets[ticket] = TicketInfo(
|
||||
username = username,
|
||||
createdAt = now,
|
||||
expiresAt = now + TICKET_VALIDITY_MS
|
||||
)
|
||||
|
||||
return ticket
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并消费票据(一次性使用)
|
||||
* @param ticket 票据
|
||||
* @return 用户名,如果票据无效则返回 null
|
||||
*/
|
||||
fun validateAndConsumeTicket(ticket: String): String? {
|
||||
val ticketInfo = tickets.remove(ticket) ?: return null
|
||||
|
||||
// 检查是否过期
|
||||
if (System.currentTimeMillis() > ticketInfo.expiresAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ticketInfo.username
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期票据
|
||||
*/
|
||||
private fun cleanupExpiredTickets() {
|
||||
val now = System.currentTimeMillis()
|
||||
tickets.entries.removeIf { it.value.expiresAt < now }
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时清理过期票据(每分钟执行一次)
|
||||
* 防止过期票据长时间占用内存
|
||||
*/
|
||||
@Scheduled(fixedRate = 60_000) // 60秒 = 60000毫秒
|
||||
fun scheduledCleanup() {
|
||||
cleanupExpiredTickets()
|
||||
}
|
||||
}
|
||||
+239
-67
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
@@ -11,6 +12,8 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -25,10 +28,10 @@ import java.math.BigInteger
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val relayClientService: RelayClientService
|
||||
private val relayClientService: RelayClientService,
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||
@@ -36,9 +39,16 @@ class BlockchainService(
|
||||
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// Polymarket 代理工厂合约地址(Polygon 主网)
|
||||
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户)
|
||||
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
|
||||
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
private val safeProxyFactoryAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
|
||||
// Polymarket Magic 代理工厂合约地址(Polygon 主网,用于邮箱/OAuth 登录用户)
|
||||
// 合约地址: 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052
|
||||
private val magicProxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
|
||||
// Magic Proxy 的 init code hash(用于 CREATE2 计算)
|
||||
private val magicProxyInitCodeHash = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"
|
||||
|
||||
// ConditionalTokens 合约地址(Polygon 主网)
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
@@ -63,79 +73,174 @@ class BlockchainService(
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketDataApi::class.java)
|
||||
}
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi? by lazy {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(polygonRpcUrl)
|
||||
}
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Polymarket 代理钱包地址
|
||||
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
|
||||
* 根据指定的钱包类型返回对应的代理地址
|
||||
*
|
||||
* Polymarket 有两种代理钱包类型:
|
||||
* 1. Magic Proxy(邮箱/OAuth 登录用户)- 使用 CREATE2 计算地址
|
||||
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||
*
|
||||
* @param walletAddress 用户的钱包地址(EOA)
|
||||
* @param walletType 钱包类型:"magic"(默认)或 "safe"
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
|
||||
return try {
|
||||
when (walletType.lowercase()) {
|
||||
"safe" -> {
|
||||
// Safe 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<String> {
|
||||
private suspend fun getSafeProxyAddress(walletAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法获取代理地址")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法获取代理地址。请在配置文件中设置 polygon.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 计算函数选择器
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
|
||||
// 编码地址参数
|
||||
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
|
||||
// 构建调用数据
|
||||
val data = functionSelector + encodedAddress
|
||||
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to proxyFactoryContractAddress,
|
||||
"to" to safeProxyFactoryAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
|
||||
// 使用 Gson 解析 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 余额
|
||||
@@ -146,12 +251,6 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法查询 USDC 余额")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法查询 USDC 余额。请在配置文件中设置 polygon.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
// 检查代理地址是否为空
|
||||
if (proxyAddress.isBlank()) {
|
||||
logger.error("代理地址为空,无法查询余额")
|
||||
@@ -172,7 +271,7 @@ class BlockchainService(
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
*/
|
||||
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 构建 ERC-20 balanceOf 函数调用
|
||||
// function signature: balanceOf(address) -> bytes4(0x70a08231)
|
||||
@@ -207,7 +306,9 @@ class BlockchainService(
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexBalance = rpcResponse.result?.asString
|
||||
?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 将十六进制转换为 BigDecimal(USDC 有 6 位小数)
|
||||
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
|
||||
@@ -263,13 +364,7 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法计算 tokenId")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法计算 tokenId"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 验证 outcomeIndex
|
||||
if (outcomeIndex < 0) {
|
||||
@@ -308,7 +403,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("调用 getCollectionId 失败: ${collectionIdResult.error}"))
|
||||
}
|
||||
|
||||
val collectionId = collectionIdResult.result ?: return Result.failure(Exception("getCollectionId 返回结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val collectionId = collectionIdResult.result?.asString
|
||||
?: return Result.failure(Exception("getCollectionId 返回结果为空"))
|
||||
|
||||
// 2. 调用 getPositionId(collateralToken, collectionId)
|
||||
val getPositionIdSelector = EthereumUtils.getFunctionSelector("getPositionId(address,bytes32)")
|
||||
@@ -338,7 +435,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("调用 getPositionId 失败: ${positionIdResult.error}"))
|
||||
}
|
||||
|
||||
val tokenId = positionIdResult.result ?: return Result.failure(Exception("getPositionId 返回结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val tokenId = positionIdResult.result?.asString
|
||||
?: return Result.failure(Exception("getPositionId 返回结果为空"))
|
||||
val tokenIdBigInt = EthereumUtils.decodeUint256(tokenId)
|
||||
|
||||
Result.success(tokenIdBigInt.toString())
|
||||
@@ -448,7 +547,7 @@ class BlockchainService(
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
private suspend fun getProxyNonce(proxyAddress: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取
|
||||
val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()")
|
||||
@@ -474,7 +573,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("获取 Proxy nonce 失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexNonce = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("Proxy nonce 结果为空"))
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce)
|
||||
return Result.success(nonce)
|
||||
}
|
||||
@@ -483,7 +584,7 @@ class BlockchainService(
|
||||
* 获取交易 nonce
|
||||
*/
|
||||
private suspend fun getTransactionCount(address: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionCount",
|
||||
@@ -502,7 +603,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("获取 nonce 失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexNonce = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("nonce 结果为空"))
|
||||
val nonce = EthereumUtils.decodeUint256(hexNonce)
|
||||
return Result.success(nonce)
|
||||
}
|
||||
@@ -511,7 +614,7 @@ class BlockchainService(
|
||||
* 获取 gas price
|
||||
*/
|
||||
private suspend fun getGasPrice(): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_gasPrice",
|
||||
@@ -528,7 +631,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("获取 gas price 失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexGasPrice = rpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("gas price 结果为空"))
|
||||
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice)
|
||||
return Result.success(gasPrice)
|
||||
}
|
||||
@@ -602,10 +707,79 @@ class BlockchainService(
|
||||
return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
|
||||
// 使用 Gson 解析 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<Pair<payoutDenominator, payouts>>
|
||||
* - payoutDenominator: 支付分母(通常为 1)
|
||||
* - payouts: 每个 outcome 的支付金额数组(0 或 1)
|
||||
* - 如果 payouts[outcomeIndex] == 1,表示该 outcome 赢了
|
||||
* - 如果 payouts[outcomeIndex] == 0,表示该 outcome 输了
|
||||
* - 如果 payouts 为空,表示市场尚未结算
|
||||
*/
|
||||
suspend fun getCondition(conditionId: String): Result<Pair<BigInteger, List<BigInteger>>> {
|
||||
return try {
|
||||
// 验证 conditionId 格式
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 构建 getCondition(bytes32) 函数调用
|
||||
// 函数签名: getCondition(bytes32)
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
val data = functionSelector + encodedConditionId
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 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 交易哈希
|
||||
@@ -613,11 +787,7 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getTransactionDetails(txHash: String): Result<String> {
|
||||
return try {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL"))
|
||||
}
|
||||
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 查询交易
|
||||
val txRequest = JsonRpcRequest(
|
||||
@@ -635,7 +805,9 @@ class BlockchainService(
|
||||
return Result.failure(Exception("查询交易失败: ${txRpcResponse.error.message}"))
|
||||
}
|
||||
|
||||
val txResult = txRpcResponse.result ?: return Result.failure(Exception("交易结果为空"))
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val txResult = txRpcResponse.result?.toString()
|
||||
?: return Result.failure(Exception("交易结果为空"))
|
||||
|
||||
// 查询交易回执(包含内部调用和事件日志)
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
@@ -652,7 +824,7 @@ class BlockchainService(
|
||||
val receiptResult = if (receiptRpcResponse.error != null) {
|
||||
"交易回执查询失败: ${receiptRpcResponse.error.message}"
|
||||
} else {
|
||||
receiptRpcResponse.result ?: "交易回执为空(可能还在打包中)"
|
||||
receiptRpcResponse.result?.toString() ?: "交易回执为空(可能还在打包中)"
|
||||
}
|
||||
|
||||
Result.success("交易信息:\n$txResult\n\n交易回执:\n$receiptResult")
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 市场价格服务
|
||||
* 统一封装从不同数据源获取市场价格的逻辑
|
||||
* 数据源包括:
|
||||
* 1. 链上 RPC 查询(市场结算结果)
|
||||
* 2. CLOB API(订单簿价格)
|
||||
*/
|
||||
@Service
|
||||
class MarketPriceService(
|
||||
private val blockchainService: BlockchainService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val cryptoUtils: CryptoUtils
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
|
||||
|
||||
/**
|
||||
* 获取当前市场最新价
|
||||
* 优先级:
|
||||
* 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0)
|
||||
* 2. CLOB API 查询订单簿价格(最准确,使用 bestBid)
|
||||
*
|
||||
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
|
||||
*
|
||||
* @param marketId 市场ID
|
||||
* @param outcomeIndex 结果索引
|
||||
* @return 市场价格(已截位到 4 位小数)
|
||||
* @throws IllegalStateException 如果所有数据源都失败
|
||||
*/
|
||||
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||
// 1. 优先从链上查询市场结算结果
|
||||
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
|
||||
if (chainPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 2. 从 CLOB API 查询订单簿价格(最准确)
|
||||
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
|
||||
if (orderbookPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 如果所有数据源都失败,抛出异常
|
||||
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)"
|
||||
logger.error(errorMsg)
|
||||
throw IllegalStateException(errorMsg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从链上查询市场结算结果获取价格
|
||||
* 如果市场已结算:
|
||||
* - payout > 0(赢了)→ 返回 1.0
|
||||
* - payout == 0(输了)→ 返回 0.0
|
||||
* 如果市场未结算或查询失败,返回 null
|
||||
*/
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||
return try {
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
onSuccess = { (_, payouts) ->
|
||||
// 如果 payouts 不为空,说明市场已结算
|
||||
if (payouts.isNotEmpty() && outcomeIndex < payouts.size) {
|
||||
val payout = payouts[outcomeIndex]
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ONE
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
null
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从 CLOB API 查询订单簿价格
|
||||
* 获取订单簿的 bestBid 和 bestAsk,计算 midpoint = (bestBid + bestAsk) / 2
|
||||
* 订单簿数据最准确,反映当前市场真实价格
|
||||
* 如果查询失败,返回 null
|
||||
*/
|
||||
private suspend fun getPriceFromClobOrderbook(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||
return try {
|
||||
// 获取 tokenId(用于查询特定 outcome 的订单簿)
|
||||
val tokenIdResult = blockchainService.getTokenId(marketId, outcomeIndex)
|
||||
if (!tokenIdResult.isSuccess) {
|
||||
return null
|
||||
}
|
||||
|
||||
val tokenId = tokenIdResult.getOrNull() ?: return null
|
||||
|
||||
// 尝试使用带鉴权的 CLOB API,如果没有则使用不带鉴权的 API
|
||||
val clobApi = try {
|
||||
getAuthenticatedClobApi() ?: retrofitFactory.createClobApiWithoutAuth()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("获取带鉴权的 CLOB API 失败,使用不带鉴权的 API: ${e.message}")
|
||||
retrofitFactory.createClobApiWithoutAuth()
|
||||
}
|
||||
|
||||
val orderbookResponse = clobApi.getOrderbook(tokenId = tokenId, market = null)
|
||||
|
||||
if (!orderbookResponse.isSuccessful || orderbookResponse.body() == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val orderbook = orderbookResponse.body()!!
|
||||
|
||||
// 获取 bestBid(最高买入价):从 bids 中找到价格最大的
|
||||
// bids 表示买入订单列表,价格越高表示愿意出的价格越高
|
||||
val bestBid = orderbook.bids
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.maxOrNull()
|
||||
|
||||
// 获取 bestAsk(最低卖出价):从 asks 中找到价格最小的
|
||||
// asks 表示卖出订单列表,价格越低表示愿意卖的价格越低
|
||||
val bestAsk = orderbook.asks
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.minOrNull()
|
||||
|
||||
// 由于主要用于卖出场景,优先使用 bestBid(最高买入价,卖给愿意买入的人)
|
||||
// 如果没有 bestBid,则使用 midpoint 或 bestAsk
|
||||
if (bestBid != null) {
|
||||
logger.debug("从订单簿获取价格(bestBid): marketId=$marketId, outcomeIndex=$outcomeIndex, bestBid=$bestBid, bestAsk=$bestAsk")
|
||||
return bestBid
|
||||
} else if (bestAsk != null && bestAsk > BigDecimal.ZERO) {
|
||||
// 如果没有 bestBid,使用 bestAsk 作为备选
|
||||
logger.debug("从订单簿获取价格(bestAsk): marketId=$marketId, outcomeIndex=$outcomeIndex, bestAsk=$bestAsk")
|
||||
return bestAsk
|
||||
}
|
||||
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
logger.debug("CLOB API 查询订单簿失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带鉴权的 CLOB API 客户端
|
||||
* 使用第一个有 API 凭证的账户
|
||||
* 如果都没有,返回 null
|
||||
*/
|
||||
private fun getAuthenticatedClobApi(): PolymarketClobApi? {
|
||||
return try {
|
||||
// 使用第一个有 API 凭证的账户
|
||||
val account = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
.firstOrNull { it.apiKey != null && it.apiSecret != null && it.apiPassphrase != null }
|
||||
|
||||
if (account == null || account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiKey = account.apiKey
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解密 API Secret 失败: ${e.message}")
|
||||
return null
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解密 API Passphrase 失败: ${e.message}")
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建带鉴权的 CLOB API 客户端
|
||||
retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("获取带鉴权的 CLOB API 失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-11
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||
@@ -19,7 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
@Service
|
||||
class PolymarketApiKeyService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String
|
||||
private val clobBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java)
|
||||
@@ -52,34 +54,36 @@ class PolymarketApiKeyService(
|
||||
try {
|
||||
// 先尝试获取现有的 API 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)
|
||||
}
|
||||
+15
-5
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
@@ -300,13 +300,23 @@ class PolymarketClobService(
|
||||
)
|
||||
|
||||
val response = authenticatedClobApi.getOrder(orderId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
Result.success(body)
|
||||
} else {
|
||||
// 响应体为空,可能是订单不存在或已过期
|
||||
logger.warn("获取订单详情失败: 响应体为空, orderId=$orderId, code=${response.code()}")
|
||||
Result.failure(Exception("订单不存在或已过期: orderId=$orderId"))
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("获取订单详情失败: ${response.code()} ${response.message()}"))
|
||||
// HTTP 状态码不是 2xx
|
||||
val errorBody = response.errorBody()?.string()?.take(200) ?: "无错误详情"
|
||||
logger.warn("获取订单详情失败: HTTP ${response.code()}, orderId=$orderId, errorBody=$errorBody")
|
||||
Result.failure(Exception("获取订单详情失败: HTTP ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取订单详情异常: ${e.message}", e)
|
||||
logger.error("获取订单详情异常: orderId=$orderId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 频率限制服务(使用内存缓存)
|
||||
*/
|
||||
@Service
|
||||
class RateLimitService {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
|
||||
|
||||
// 重置密码限速配置
|
||||
@Value("\${rate-limit.reset-password.max-attempts:3}")
|
||||
private var resetPasswordMaxAttempts: Int = 3
|
||||
|
||||
@Value("\${rate-limit.reset-password.window-seconds:60}")
|
||||
private var resetPasswordWindowSeconds: Long = 60
|
||||
|
||||
// 登录限速配置
|
||||
@Value("\${rate-limit.login.max-attempts:5}")
|
||||
private var loginMaxAttempts: Int = 5
|
||||
|
||||
@Value("\${rate-limit.login.window-seconds:300}")
|
||||
private var loginWindowSeconds: Long = 300 // 5分钟
|
||||
|
||||
@Value("\${rate-limit.login.lockout-seconds:900}")
|
||||
private var loginLockoutSeconds: Long = 900 // 15分钟
|
||||
|
||||
// 全局尝试记录列表(时间戳),所有请求共享
|
||||
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
|
||||
|
||||
// 登录失败尝试记录(IP -> 时间戳列表)
|
||||
private val loginFailedAttempts = ConcurrentHashMap<String, MutableList<Long>>()
|
||||
|
||||
// 登录锁定记录(IP -> 锁定结束时间)
|
||||
private val loginLockouts = ConcurrentHashMap<String, Long>()
|
||||
|
||||
/**
|
||||
* 检查重置密码频率限制(全局限制,不按IP)
|
||||
* @return Result,如果超过限制则返回失败
|
||||
*/
|
||||
fun checkResetPasswordRateLimit(): Result<Unit> {
|
||||
val now = System.currentTimeMillis()
|
||||
val windowStart = now - (resetPasswordWindowSeconds * 1000)
|
||||
|
||||
// 获取当前尝试记录列表
|
||||
val attempts = resetPasswordAttempts.get()
|
||||
|
||||
// 清理过期记录(超过时间窗口的记录)
|
||||
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
|
||||
|
||||
// 检查是否超过限制
|
||||
if (validAttempts.size >= resetPasswordMaxAttempts) {
|
||||
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts")
|
||||
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试"))
|
||||
}
|
||||
|
||||
// 记录本次尝试
|
||||
validAttempts.add(now)
|
||||
resetPasswordAttempts.set(validAttempts)
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录频率限制(按IP限制)
|
||||
* @param ipAddress 客户端IP地址
|
||||
* @return Result,如果被锁定或超过限制则返回失败
|
||||
*/
|
||||
fun checkLoginRateLimit(ipAddress: String): Result<Unit> {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// 检查是否被锁定
|
||||
val lockoutEndTime = loginLockouts[ipAddress]
|
||||
if (lockoutEndTime != null) {
|
||||
if (now < lockoutEndTime) {
|
||||
val remainingSeconds = (lockoutEndTime - now) / 1000
|
||||
logger.warn("登录锁定中: ip=$ipAddress, remainingSeconds=$remainingSeconds")
|
||||
return Result.failure(IllegalStateException("账户已被锁定,请${remainingSeconds}秒后再试"))
|
||||
} else {
|
||||
// 锁定已过期,清除锁定记录
|
||||
loginLockouts.remove(ipAddress)
|
||||
loginFailedAttempts.remove(ipAddress)
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录失败尝试
|
||||
* @param ipAddress 客户端IP地址
|
||||
* @return 如果触发锁定返回锁定信息,否则返回 null
|
||||
*/
|
||||
fun recordLoginFailure(ipAddress: String): String? {
|
||||
val now = System.currentTimeMillis()
|
||||
val windowStart = now - (loginWindowSeconds * 1000)
|
||||
|
||||
// 获取或创建该IP的尝试记录
|
||||
val attempts = loginFailedAttempts.computeIfAbsent(ipAddress) { mutableListOf() }
|
||||
|
||||
// 清理过期记录并添加新记录
|
||||
synchronized(attempts) {
|
||||
attempts.removeIf { it < windowStart }
|
||||
attempts.add(now)
|
||||
|
||||
// 检查是否需要锁定
|
||||
if (attempts.size >= loginMaxAttempts) {
|
||||
val lockoutEndTime = now + (loginLockoutSeconds * 1000)
|
||||
loginLockouts[ipAddress] = lockoutEndTime
|
||||
logger.warn("登录锁定触发: ip=$ipAddress, attempts=${attempts.size}, lockoutSeconds=$loginLockoutSeconds")
|
||||
return "登录失败次数过多,账户已被锁定${loginLockoutSeconds / 60}分钟"
|
||||
}
|
||||
}
|
||||
|
||||
val remainingAttempts = loginMaxAttempts - attempts.size
|
||||
logger.warn("登录失败: ip=$ipAddress, attempts=${attempts.size}/$loginMaxAttempts, remainingAttempts=$remainingAttempts")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功时清除失败记录
|
||||
* @param ipAddress 客户端IP地址
|
||||
*/
|
||||
fun clearLoginFailures(ipAddress: String) {
|
||||
loginFailedAttempts.remove(ipAddress)
|
||||
loginLockouts.remove(ipAddress)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.api.OrderbookResponse
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.lt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 跟单过滤条件检查服务
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingFilterService(
|
||||
private val clobService: PolymarketClobService,
|
||||
private val accountService: AccountService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
|
||||
|
||||
/**
|
||||
* 检查过滤条件
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tokenId token ID(用于获取订单簿)
|
||||
* @param tradePrice Leader 交易价格,用于价格区间检查
|
||||
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
|
||||
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
* @return 过滤结果
|
||||
*/
|
||||
suspend fun checkFilters(
|
||||
copyTrading: CopyTrading,
|
||||
tokenId: String,
|
||||
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
|
||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
): FilterResult {
|
||||
// 1. 价格区间检查(如果配置了价格区间)
|
||||
if (tradePrice != null) {
|
||||
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
|
||||
if (!priceRangeCheck.isPassed) {
|
||||
return FilterResult.priceRangeFailed(priceRangeCheck.reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否需要获取订单簿
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||
|
||||
if (!needOrderbook) {
|
||||
// 不需要订单簿,直接通过
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 3. 获取订单簿(仅在需要时,只请求一次)
|
||||
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
|
||||
if (!orderbookResult.isSuccess) {
|
||||
val error = orderbookResult.exceptionOrNull()
|
||||
return FilterResult.orderbookError("获取订单簿失败: ${error?.message ?: "未知错误"}")
|
||||
}
|
||||
|
||||
val orderbook = orderbookResult.getOrNull()
|
||||
?: return FilterResult.orderbookEmpty()
|
||||
|
||||
// 4. 买一卖一价差过滤(如果配置了)
|
||||
if (copyTrading.maxSpread != null) {
|
||||
val spreadCheck = checkSpread(copyTrading, orderbook)
|
||||
if (!spreadCheck.isPassed) {
|
||||
return FilterResult.spreadFailed(spreadCheck.reason, orderbook)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 订单深度过滤(如果配置了,检查所有方向)
|
||||
if (copyTrading.minOrderDepth != null) {
|
||||
val depthCheck = checkOrderDepth(copyTrading, orderbook)
|
||||
if (!depthCheck.isPassed) {
|
||||
return FilterResult.orderDepthFailed(depthCheck.reason, orderbook)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
}
|
||||
|
||||
return FilterResult.passed(orderbook)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查价格区间
|
||||
* @param copyTrading 跟单配置
|
||||
* @param tradePrice Leader 交易价格
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private fun checkPriceRange(
|
||||
copyTrading: CopyTrading,
|
||||
tradePrice: BigDecimal
|
||||
): FilterResult {
|
||||
// 如果未配置价格区间,直接通过
|
||||
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 检查最低价格
|
||||
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
||||
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
||||
}
|
||||
|
||||
// 检查最高价格
|
||||
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
||||
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查买一卖一价差
|
||||
* bestBid: 买盘中的最高价格(最大值)
|
||||
* bestAsk: 卖盘中的最低价格(最小值)
|
||||
*/
|
||||
private fun checkSpread(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse
|
||||
): FilterResult {
|
||||
// 如果未启用价差过滤,直接通过
|
||||
if (copyTrading.maxSpread == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
|
||||
val bestBid = orderbook.bids
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.maxOrNull()
|
||||
|
||||
// 获取卖盘中的最低价格(bestAsk = asks 中的最小值)
|
||||
val bestAsk = orderbook.asks
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.minOrNull()
|
||||
|
||||
if (bestBid == null || bestAsk == null) {
|
||||
return FilterResult.spreadFailed("订单簿缺少买一或卖一价格", orderbook)
|
||||
}
|
||||
|
||||
// 计算价差(绝对价格)
|
||||
val spread = bestAsk.subtract(bestBid)
|
||||
|
||||
if (spread.gt(copyTrading.maxSpread)) {
|
||||
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查订单深度(检查所有方向:买盘和卖盘的总深度)
|
||||
*/
|
||||
private fun checkOrderDepth(
|
||||
copyTrading: CopyTrading,
|
||||
orderbook: OrderbookResponse
|
||||
): FilterResult {
|
||||
// 如果未启用订单深度过滤,直接通过
|
||||
if (copyTrading.minOrderDepth == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 计算买盘(bids)总深度
|
||||
var bidsDepth = BigDecimal.ZERO
|
||||
for (order in orderbook.bids) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
bidsDepth = bidsDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
// 计算卖盘(asks)总深度
|
||||
var asksDepth = BigDecimal.ZERO
|
||||
for (order in orderbook.asks) {
|
||||
val price = order.price.toSafeBigDecimal()
|
||||
val size = order.size.toSafeBigDecimal()
|
||||
val orderAmount = price.multi(size)
|
||||
asksDepth = asksDepth.add(orderAmount)
|
||||
}
|
||||
|
||||
// 计算总深度(买盘 + 卖盘)
|
||||
val totalDepth = bidsDepth.add(asksDepth)
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查仓位限制(按市场检查)
|
||||
* @param copyTrading 跟单配置
|
||||
* @param copyOrderAmount 跟单金额(USDC)
|
||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private suspend fun checkPositionLimits(
|
||||
copyTrading: CopyTrading,
|
||||
copyOrderAmount: BigDecimal,
|
||||
marketId: String
|
||||
): FilterResult {
|
||||
// 如果未配置仓位限制,直接通过
|
||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取账户的所有仓位信息
|
||||
val positionsResult = accountService.getAllPositions()
|
||||
if (positionsResult.isFailure) {
|
||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||
}
|
||||
|
||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||
|
||||
// 过滤出当前账户且该市场的仓位
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||
}
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue)
|
||||
val currentPositionValue = marketPositions.sumOf { position ->
|
||||
position.currentValue.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
return FilterResult.maxPositionValueFailed(
|
||||
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位)
|
||||
val currentPositionCount = marketPositions.size
|
||||
|
||||
// 检查:该市场的当前仓位数量 <= 最大仓位数量
|
||||
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
|
||||
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
|
||||
if (currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
} catch (e: Exception) {
|
||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
||||
// 如果检查异常,为了安全起见,不通过检查
|
||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-22
@@ -1,11 +1,14 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -82,9 +85,10 @@ class CopyTradingService(
|
||||
supportSell = request.supportSell ?: template.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount
|
||||
)
|
||||
} else {
|
||||
// 手动输入(所有字段必须提供)
|
||||
@@ -109,9 +113,10 @@ class CopyTradingService(
|
||||
supportSell = request.supportSell ?: true,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,22 +141,24 @@ class CopyTradingService(
|
||||
supportSell = config.supportSell,
|
||||
minOrderDepth = config.minOrderDepth,
|
||||
maxSpread = config.maxSpread,
|
||||
minOrderbookDepth = config.minOrderbookDepth,
|
||||
minPrice = config.minPrice,
|
||||
maxPrice = config.maxPrice,
|
||||
maxPositionValue = config.maxPositionValue,
|
||||
maxPositionCount = config.maxPositionCount,
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: false
|
||||
)
|
||||
|
||||
val saved = copyTradingRepository.save(copyTrading)
|
||||
|
||||
// 如果跟单已启用,重新启动监听(确保状态完全同步)
|
||||
// 如果跟单已启用,更新 Leader 监听和账户监听(增量更新,不重启所有监听)
|
||||
if (saved.enabled) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.restartMonitoring()
|
||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||
monitorService.updateAccountMonitoring(saved.accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重新启动跟单监听失败", e)
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,9 +209,10 @@ class CopyTradingService(
|
||||
supportSell = request.supportSell ?: copyTrading.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
|
||||
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
@@ -212,12 +220,13 @@ class CopyTradingService(
|
||||
|
||||
val saved = copyTradingRepository.save(updated)
|
||||
|
||||
// 重新启动监听(确保状态完全同步)
|
||||
// 更新 Leader 监听和账户监听(增量更新,根据 enabled 状态决定添加或移除)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.restartMonitoring()
|
||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||
monitorService.updateAccountMonitoring(saved.accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重新启动跟单监听失败", e)
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,14 +324,17 @@ class CopyTradingService(
|
||||
val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
|
||||
|
||||
val leaderId = copyTrading.leaderId
|
||||
val accountId = copyTrading.accountId
|
||||
copyTradingRepository.delete(copyTrading)
|
||||
|
||||
// 重新启动监听(确保状态完全同步)
|
||||
// 更新 Leader 监听和账户监听(检查是否还有其他启用的跟单配置)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.restartMonitoring()
|
||||
monitorService.removeLeaderMonitoring(leaderId)
|
||||
monitorService.updateAccountMonitoring(accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重新启动跟单监听失败", e)
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +351,7 @@ class CopyTradingService(
|
||||
fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> {
|
||||
return try {
|
||||
// 验证账户是否存在
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
@@ -380,8 +392,8 @@ class CopyTradingService(
|
||||
*/
|
||||
private fun toDto(
|
||||
copyTrading: CopyTrading,
|
||||
account: com.wrbug.polymarketbot.entity.Account,
|
||||
leader: com.wrbug.polymarketbot.entity.Leader
|
||||
account: Account,
|
||||
leader: Leader
|
||||
): CopyTradingDto {
|
||||
return CopyTradingDto(
|
||||
id = copyTrading.id!!,
|
||||
@@ -408,9 +420,10 @@ class CopyTradingService(
|
||||
supportSell = copyTrading.supportSell,
|
||||
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
|
||||
maxSpread = copyTrading.maxSpread?.toPlainString(),
|
||||
minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(),
|
||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||
maxPositionCount = copyTrading.maxPositionCount,
|
||||
configName = copyTrading.configName,
|
||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||
createdAt = copyTrading.createdAt,
|
||||
@@ -438,8 +451,9 @@ class CopyTradingService(
|
||||
val supportSell: Boolean,
|
||||
val minOrderDepth: BigDecimal?,
|
||||
val maxSpread: BigDecimal?,
|
||||
val minOrderbookDepth: BigDecimal?,
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?
|
||||
val maxPrice: BigDecimal?,
|
||||
val maxPositionValue: BigDecimal?,
|
||||
val maxPositionCount: Int?
|
||||
)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.api.OrderbookResponse
|
||||
|
||||
/**
|
||||
* 过滤结果状态枚举
|
||||
*/
|
||||
enum class FilterStatus {
|
||||
/** 通过 */
|
||||
PASSED,
|
||||
/** 失败:价格区间 */
|
||||
FAILED_PRICE_RANGE,
|
||||
/** 失败:订单簿获取失败 */
|
||||
FAILED_ORDERBOOK_ERROR,
|
||||
/** 失败:订单簿为空 */
|
||||
FAILED_ORDERBOOK_EMPTY,
|
||||
/** 失败:价差过大 */
|
||||
FAILED_SPREAD,
|
||||
/** 失败:订单深度不足 */
|
||||
FAILED_ORDER_DEPTH,
|
||||
/** 失败:超过最大仓位金额 */
|
||||
FAILED_MAX_POSITION_VALUE,
|
||||
/** 失败:超过最大仓位数量 */
|
||||
FAILED_MAX_POSITION_COUNT
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤结果
|
||||
*/
|
||||
data class FilterResult(
|
||||
/** 过滤状态 */
|
||||
val status: FilterStatus,
|
||||
/** 失败原因(仅在失败时有效) */
|
||||
val reason: String = "",
|
||||
/** 订单簿(仅在需要时返回) */
|
||||
val orderbook: OrderbookResponse? = null
|
||||
) {
|
||||
/** 是否通过 */
|
||||
val isPassed: Boolean
|
||||
get() = status == FilterStatus.PASSED
|
||||
|
||||
companion object {
|
||||
/** 通过 */
|
||||
fun passed(orderbook: OrderbookResponse? = null) = FilterResult(
|
||||
status = FilterStatus.PASSED,
|
||||
orderbook = orderbook
|
||||
)
|
||||
|
||||
/** 价格区间失败 */
|
||||
fun priceRangeFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_PRICE_RANGE,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 订单簿获取失败 */
|
||||
fun orderbookError(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_ORDERBOOK_ERROR,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 订单簿为空 */
|
||||
fun orderbookEmpty() = FilterResult(
|
||||
status = FilterStatus.FAILED_ORDERBOOK_EMPTY,
|
||||
reason = "订单簿为空"
|
||||
)
|
||||
|
||||
/** 价差过大 */
|
||||
fun spreadFailed(reason: String, orderbook: OrderbookResponse) = FilterResult(
|
||||
status = FilterStatus.FAILED_SPREAD,
|
||||
reason = reason,
|
||||
orderbook = orderbook
|
||||
)
|
||||
|
||||
/** 订单深度不足 */
|
||||
fun orderDepthFailed(reason: String, orderbook: OrderbookResponse) = FilterResult(
|
||||
status = FilterStatus.FAILED_ORDER_DEPTH,
|
||||
reason = reason,
|
||||
orderbook = orderbook
|
||||
)
|
||||
|
||||
/** 超过最大仓位金额 */
|
||||
fun maxPositionValueFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 超过最大仓位数量 */
|
||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.FilteredOrderDto
|
||||
import com.wrbug.polymarketbot.dto.FilteredOrderListRequest
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.leaders
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||
import com.wrbug.polymarketbot.entity.SellMatchRecord
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
|
||||
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 跟单账户链上 WebSocket 监听服务
|
||||
* 通过统一服务订阅跟单账户的卖出和赎回事件
|
||||
* 用于更新订单状态,不再依赖轮询
|
||||
*/
|
||||
@Service
|
||||
class AccountOnChainMonitorService(
|
||||
private val unifiedOnChainWsService: UnifiedOnChainWsService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountOnChainMonitorService::class.java)
|
||||
|
||||
// 存储需要监听的账户:accountId -> Account
|
||||
private val monitoredAccounts = ConcurrentHashMap<Long, Account>()
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有跟单账户
|
||||
*/
|
||||
fun start(accounts: List<Account>) {
|
||||
// 如果没有账户,取消所有订阅
|
||||
if (accounts.isEmpty()) {
|
||||
logger.info("没有需要监听的跟单账户,取消所有订阅")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新账户列表
|
||||
monitoredAccounts.clear()
|
||||
accounts.forEach { account ->
|
||||
addAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户监听
|
||||
* 通过统一服务订阅该账户的地址
|
||||
*/
|
||||
fun addAccount(account: Account) {
|
||||
if (account.id == null) {
|
||||
logger.warn("账户 ID 为空,跳过: ${account.proxyAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val accountId = account.id!!
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredAccounts.containsKey(accountId)) {
|
||||
return
|
||||
}
|
||||
|
||||
monitoredAccounts[accountId] = account
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "ACCOUNT_$accountId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
subscriptionId = subscriptionId,
|
||||
address = account.proxyAddress,
|
||||
entityType = "ACCOUNT",
|
||||
entityId = accountId,
|
||||
callback = { txHash, httpClient, rpcApi ->
|
||||
handleAccountTransaction(accountId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("已添加跟单账户进行链上监听: accountId=${accountId}, address=${account.proxyAddress}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账户的交易
|
||||
*/
|
||||
private suspend fun handleAccountTransaction(accountId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
val account = monitoredAccounts[accountId] ?: return
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
timestamp = blockTimestamp,
|
||||
walletAddress = account.proxyAddress,
|
||||
erc20Transfers = erc20Transfers,
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
if (trade != null && trade.side == "SELL") {
|
||||
// 检测到卖出或赎回事件,更新订单状态
|
||||
handleAccountSellOrRedeem(account, trade)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理账户交易失败: accountId=$accountId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账户的卖出或赎回事件
|
||||
* 更新对应的订单状态
|
||||
*/
|
||||
private suspend fun handleAccountSellOrRedeem(account: Account, trade: TradeResponse) {
|
||||
try {
|
||||
// 获取该账户的所有启用的跟单配置
|
||||
val copyTradings = copyTradingRepository.findByAccountId(account.id!!)
|
||||
.filter { it.enabled }
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 trade 中已有的市场信息
|
||||
val marketId = trade.market // conditionId
|
||||
val outcomeIndex = trade.outcomeIndex ?: 0
|
||||
|
||||
// 计算卖出价格
|
||||
val sellPrice = trade.price.toSafeBigDecimal()
|
||||
|
||||
// 为每个跟单配置更新订单状态
|
||||
for (copyTrading in copyTradings) {
|
||||
// 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0)
|
||||
val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!)
|
||||
.filter {
|
||||
it.remainingQuantity > BigDecimal.ZERO &&
|
||||
it.marketId == marketId &&
|
||||
it.outcomeIndex == outcomeIndex
|
||||
}
|
||||
.sortedBy { it.createdAt } // 按创建时间排序(FIFO)
|
||||
|
||||
if (unmatchedOrders.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 卖出数量就是交易的 size
|
||||
val soldQuantity = trade.size.toSafeBigDecimal()
|
||||
|
||||
// 更新订单状态为已卖出
|
||||
updateOrdersAsSoldByFIFO(
|
||||
unmatchedOrders,
|
||||
soldQuantity,
|
||||
sellPrice,
|
||||
copyTrading.id!!,
|
||||
marketId,
|
||||
outcomeIndex
|
||||
)
|
||||
|
||||
logger.info("跟单账户卖出/赎回事件处理完成: accountId=${account.id}, copyTradingId=${copyTrading.id}, txHash=${trade.id}, soldQuantity=$soldQuantity, sellPrice=$sellPrice")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理账户卖出/赎回事件失败: accountId=${account.id}, txHash=${trade.id}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 FIFO 顺序更新订单为已卖出
|
||||
*/
|
||||
private suspend fun updateOrdersAsSoldByFIFO(
|
||||
orders: List<CopyOrderTracking>,
|
||||
soldQuantity: BigDecimal,
|
||||
sellPrice: BigDecimal,
|
||||
copyTradingId: Long,
|
||||
marketId: String,
|
||||
outcomeIndex: Int
|
||||
) {
|
||||
var remainingSoldQuantity = soldQuantity
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
var totalMatchedQuantity = BigDecimal.ZERO
|
||||
var totalRealizedPnl = BigDecimal.ZERO
|
||||
|
||||
for (order in orders) {
|
||||
if (remainingSoldQuantity <= BigDecimal.ZERO) {
|
||||
break
|
||||
}
|
||||
|
||||
val currentOrderRemaining = order.remainingQuantity.toSafeBigDecimal()
|
||||
val matchedQty = minOf(currentOrderRemaining, remainingSoldQuantity)
|
||||
|
||||
if (matchedQty <= BigDecimal.ZERO) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算盈亏
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchedQty)
|
||||
|
||||
// 创建匹配明细
|
||||
val detail = SellMatchDetail(
|
||||
matchRecordId = 0, // 稍后设置
|
||||
trackingId = order.id!!,
|
||||
buyOrderId = order.buyOrderId,
|
||||
matchedQuantity = matchedQty,
|
||||
buyPrice = buyPrice,
|
||||
sellPrice = sellPrice,
|
||||
realizedPnl = realizedPnl
|
||||
)
|
||||
matchDetails.add(detail)
|
||||
|
||||
totalMatchedQuantity = totalMatchedQuantity.add(matchedQty)
|
||||
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
|
||||
|
||||
// 更新订单状态
|
||||
order.matchedQuantity = order.matchedQuantity.add(matchedQty)
|
||||
order.remainingQuantity = currentOrderRemaining.subtract(matchedQty)
|
||||
order.status = if (order.remainingQuantity <= BigDecimal.ZERO) "fully_matched" else "partially_matched"
|
||||
order.updatedAt = System.currentTimeMillis()
|
||||
copyOrderTrackingRepository.save(order)
|
||||
|
||||
remainingSoldQuantity = remainingSoldQuantity.subtract(matchedQty)
|
||||
}
|
||||
|
||||
// 如果有匹配的订单,创建卖出记录
|
||||
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val sellOrderId = "AUTO_WS_${timestamp}_${copyTradingId}" // 区分 WS 自动卖出
|
||||
val leaderSellTradeId = "AUTO_WS_${timestamp}"
|
||||
|
||||
val matchRecord = SellMatchRecord(
|
||||
copyTradingId = copyTradingId,
|
||||
sellOrderId = sellOrderId,
|
||||
leaderSellTradeId = leaderSellTradeId,
|
||||
marketId = marketId,
|
||||
side = outcomeIndex.toString(),
|
||||
outcomeIndex = outcomeIndex,
|
||||
totalMatchedQuantity = totalMatchedQuantity,
|
||||
sellPrice = sellPrice,
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = true // WS 实时获取,直接标记为已更新
|
||||
)
|
||||
|
||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||
|
||||
// 保存匹配明细
|
||||
for (detail in matchDetails) {
|
||||
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
|
||||
sellMatchDetailRepository.save(savedDetail)
|
||||
}
|
||||
|
||||
logger.info("创建跟单账户链上自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除账户监听
|
||||
* 取消该账户的订阅
|
||||
*/
|
||||
fun removeAccount(accountId: Long) {
|
||||
monitoredAccounts.remove(accountId)
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "ACCOUNT_$accountId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
logger.info("已移除跟单账户的链上监听: accountId=$accountId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户监听状态
|
||||
*/
|
||||
fun updateAccountMonitoring(accountId: Long) {
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
if (account != null && account.isEnabled) {
|
||||
addAccount(account)
|
||||
} else {
|
||||
removeAccount(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
fun stop() {
|
||||
// 取消所有账户的订阅
|
||||
val accountIds = monitoredAccounts.keys.toList()
|
||||
for (accountId in accountIds) {
|
||||
removeAccount(accountId)
|
||||
}
|
||||
monitoredAccounts.clear()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* 跟单监听服务(主服务)
|
||||
* 管理所有Leader的交易监听
|
||||
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
|
||||
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket)
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingMonitorService(
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val pollingService: CopyTradingPollingService,
|
||||
private val onChainWsService: OnChainWsService,
|
||||
private val accountOnChainMonitorService: AccountOnChainMonitorService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/**
|
||||
* 系统启动时初始化监听
|
||||
*/
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
scope.launch {
|
||||
try {
|
||||
startMonitoring()
|
||||
} catch (e: Exception) {
|
||||
logger.error("启动跟单监听失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统关闭时清理资源
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
// 停止轮询和链上 WS 监听
|
||||
pollingService.stop()
|
||||
onChainWsService.stop()
|
||||
accountOnChainMonitorService.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
*/
|
||||
suspend fun startMonitoring() {
|
||||
// 1. 获取所有启用的跟单关系
|
||||
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
|
||||
|
||||
if (enabledCopyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取所有需要监听的Leader(去重)
|
||||
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
|
||||
val leaders = leaderIds.mapNotNull { leaderId ->
|
||||
leaderRepository.findById(leaderId).orElse(null)
|
||||
}
|
||||
|
||||
// 3. 获取所有需要监听的跟单账户(去重)
|
||||
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
|
||||
val accounts = accountIds.mapNotNull { accountId ->
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
}
|
||||
|
||||
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
|
||||
onChainWsService.start(leaders)
|
||||
|
||||
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
|
||||
pollingService.start(leaders)
|
||||
|
||||
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
accountOnChainMonitorService.start(accounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听(当创建新的跟单关系时调用)
|
||||
* 如果 Leader 已经在监听列表中,不重复添加
|
||||
*/
|
||||
suspend fun addLeaderMonitoring(leaderId: Long) {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return
|
||||
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听(当删除跟单关系或禁用时调用)
|
||||
* 检查该 Leader 是否还有其他启用的跟单配置
|
||||
*/
|
||||
suspend fun removeLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
// 如果还有启用的跟单配置,不移除监听
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 没有启用的跟单配置了,移除监听
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Leader监听(当跟单配置状态改变时调用)
|
||||
* 根据当前状态决定添加或移除监听
|
||||
*/
|
||||
suspend fun updateLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return
|
||||
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
// 有启用的跟单配置,确保在监听列表中
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
|
||||
// 更新账户监听(添加该配置关联的账户)
|
||||
val accountIds = copyTradings.map { it.accountId }.distinct()
|
||||
accountIds.forEach { accountId ->
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
if (account != null) {
|
||||
accountOnChainMonitorService.addAccount(account)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有启用的跟单配置,移除监听
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户监听(当跟单配置状态改变时调用)
|
||||
* 根据当前状态决定添加或移除账户监听
|
||||
*/
|
||||
suspend fun updateAccountMonitoring(accountId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
.filter { it.enabled }
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return
|
||||
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
// 有启用的跟单配置,确保账户在监听列表中
|
||||
accountOnChainMonitorService.addAccount(account)
|
||||
} else {
|
||||
// 没有启用的跟单配置,移除账户监听
|
||||
accountOnChainMonitorService.removeAccount(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启动监听(当跟单关系状态改变时调用)
|
||||
* 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新
|
||||
*/
|
||||
suspend fun restartMonitoring() {
|
||||
// 停止所有监听
|
||||
onChainWsService.stop()
|
||||
pollingService.stop()
|
||||
delay(1000) // 等待1秒
|
||||
startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
@@ -9,6 +9,7 @@ import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Response
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -72,7 +73,7 @@ class CopyTradingPollingService(
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
val leaderId = leader.id
|
||||
monitoredLeaders[leaderId] = leader
|
||||
// 初始化缓存的交易ID集合
|
||||
cachedTradeIds[leaderId] = mutableSetOf()
|
||||
@@ -178,7 +179,7 @@ class CopyTradingPollingService(
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
val leaderId = leader.id
|
||||
val leaderAddress = leader.leaderAddress
|
||||
|
||||
try {
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
@@ -11,6 +11,7 @@ import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -21,15 +22,14 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
@Service
|
||||
class CopyTradingWebSocketService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val templateRepository: CopyTradingTemplateRepository
|
||||
private val templateRepository: CopyTradingTemplateRepository,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
|
||||
private val gson = Gson()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||
@@ -65,7 +65,7 @@ class CopyTradingWebSocketService(
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
val leaderId = leader.id
|
||||
val leaderAddress = leader.leaderAddress.lowercase()
|
||||
leaderAddresses[leaderId] = leaderAddress
|
||||
|
||||
+177
@@ -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<Long, Leader>()
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有 Leader
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
// 如果没有 Leader,取消所有订阅
|
||||
if (leaders.isEmpty()) {
|
||||
logger.info("没有需要监听的 Leader,取消所有订阅")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 Leader 列表
|
||||
monitoredLeaders.clear()
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
* 通过统一服务订阅该 Leader 的地址
|
||||
*/
|
||||
fun addLeader(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id!!
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredLeaders.containsKey(leaderId)) {
|
||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
return
|
||||
}
|
||||
|
||||
monitoredLeaders[leaderId] = leader
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
subscriptionId = subscriptionId,
|
||||
address = leader.leaderAddress,
|
||||
entityType = "LEADER",
|
||||
entityId = leaderId,
|
||||
callback = { txHash, httpClient, rpcApi ->
|
||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Leader 的交易
|
||||
*/
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
timestamp = blockTimestamp,
|
||||
walletAddress = leader.leaderAddress,
|
||||
erc20Transfers = erc20Transfers,
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
if (trade != null) {
|
||||
// 调用 processTrade 处理交易
|
||||
copyOrderTrackingService.processTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
source = "onchain-ws"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
* 取消该 Leader 的订阅
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
fun stop() {
|
||||
// 取消所有 Leader 的订阅
|
||||
val leaderIds = monitoredLeaders.keys.toList()
|
||||
for (leaderId in leaderIds) {
|
||||
removeLeader(leaderId)
|
||||
}
|
||||
monitoredLeaders.clear()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 链上 WebSocket 工具类
|
||||
* 提取公共的交易解析、工具函数等逻辑
|
||||
*/
|
||||
object OnChainWsUtils {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OnChainWsUtils::class.java)
|
||||
|
||||
// 创建 Gson 实例(与 GsonConfig 中的配置一致,使用 lenient 模式)
|
||||
private val gson: Gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串数组
|
||||
* @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]"
|
||||
* @return 字符串列表,如果解析失败返回空列表
|
||||
*/
|
||||
private fun parseStringArray(jsonString: String?): List<String> {
|
||||
if (jsonString.isNullOrBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return try {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// 合约地址
|
||||
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
|
||||
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
||||
const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
|
||||
|
||||
/**
|
||||
* ERC20 Transfer 数据类
|
||||
*/
|
||||
data class Erc20Transfer(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val value: BigInteger
|
||||
)
|
||||
|
||||
/**
|
||||
* ERC1155 Transfer 数据类
|
||||
*/
|
||||
data class Erc1155Transfer(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val tokenId: BigInteger,
|
||||
val value: BigInteger
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场信息数据类
|
||||
*/
|
||||
data class MarketInfo(
|
||||
val conditionId: String,
|
||||
val outcomeIndex: Int?, // 可空,因为可能找不到对应的 tokenId
|
||||
val outcome: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析 receipt 中的 Transfer 日志
|
||||
*/
|
||||
fun parseReceiptTransfers(logs: JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
|
||||
val erc20 = mutableListOf<Erc20Transfer>()
|
||||
val erc1155 = mutableListOf<Erc1155Transfer>()
|
||||
|
||||
for (logElement in logs) {
|
||||
val log = logElement.asJsonObject
|
||||
val address = log.get("address")?.asString?.lowercase() ?: continue
|
||||
val topicsArray = log.getAsJsonArray("topics") ?: continue
|
||||
val topics = topicsArray.mapNotNull { it.asString }
|
||||
if (topics.isEmpty()) continue
|
||||
|
||||
val t0 = topics[0].lowercase()
|
||||
val data = log.get("data")?.asString ?: "0x"
|
||||
|
||||
// USDC ERC20 Transfer
|
||||
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||
val from = topicToAddress(topics[1])
|
||||
val to = topicToAddress(topics[2])
|
||||
val value = hexToBigInt(data)
|
||||
erc20.add(Erc20Transfer(from, to, value))
|
||||
continue
|
||||
}
|
||||
|
||||
// ERC1155 TransferSingle
|
||||
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
|
||||
val from = topicToAddress(topics[2])
|
||||
val to = topicToAddress(topics[3])
|
||||
val bytes = bytesFromHex(data)
|
||||
if (bytes.size >= 64) {
|
||||
val tokenId = sliceBigInt32(bytes, 0)
|
||||
val value = sliceBigInt32(bytes, 32)
|
||||
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ERC1155 TransferBatch
|
||||
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
|
||||
val from = topicToAddress(topics[2])
|
||||
val to = topicToAddress(topics[3])
|
||||
val bytes = bytesFromHex(data)
|
||||
if (bytes.size < 64) continue
|
||||
|
||||
val offIds = sliceBigInt32(bytes, 0).toInt()
|
||||
val offVals = sliceBigInt32(bytes, 32).toInt()
|
||||
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
|
||||
|
||||
val nIds = sliceBigInt32(bytes, offIds).toInt()
|
||||
val nVals = sliceBigInt32(bytes, offVals).toInt()
|
||||
if (nIds != nVals) continue
|
||||
|
||||
val idsStart = offIds + 32
|
||||
val valsStart = offVals + 32
|
||||
for (i in 0 until nIds) {
|
||||
val ib = idsStart + i * 32
|
||||
val vb = valsStart + i * 32
|
||||
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
|
||||
val tokenId = sliceBigInt32(bytes, ib)
|
||||
val value = sliceBigInt32(bytes, vb)
|
||||
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(erc20, erc1155)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Transfer 日志解析交易信息
|
||||
*/
|
||||
suspend fun parseTradeFromTransfers(
|
||||
txHash: String,
|
||||
timestamp: Long?,
|
||||
walletAddress: String,
|
||||
erc20Transfers: List<Erc20Transfer>,
|
||||
erc1155Transfers: List<Erc1155Transfer>,
|
||||
retrofitFactory: RetrofitFactory
|
||||
): TradeResponse? {
|
||||
val wallet = walletAddress.lowercase()
|
||||
|
||||
// 计算 USDC 流入和流出
|
||||
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
|
||||
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
||||
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
|
||||
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
||||
|
||||
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
|
||||
val inById = mutableMapOf<BigInteger, BigInteger>()
|
||||
val outById = mutableMapOf<BigInteger, BigInteger>()
|
||||
for (t in erc1155Transfers) {
|
||||
if (t.to.lowercase() == wallet) {
|
||||
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
||||
}
|
||||
if (t.from.lowercase() == wallet) {
|
||||
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
||||
}
|
||||
}
|
||||
|
||||
// 找到最大的流入和流出 tokenId
|
||||
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
|
||||
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
|
||||
|
||||
val (bestInId, bestInVal) = best(inById)
|
||||
val (bestOutId, bestOutVal) = best(outById)
|
||||
|
||||
// 判断交易方向
|
||||
var side: String? = null
|
||||
var asset: BigInteger? = null
|
||||
var sizeRaw = BigInteger.ZERO
|
||||
var usdcRaw = BigInteger.ZERO
|
||||
|
||||
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
|
||||
// BUY: 收到 token,支付 USDC
|
||||
side = "BUY"
|
||||
asset = bestInId
|
||||
sizeRaw = bestInVal
|
||||
usdcRaw = usdcOut
|
||||
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
|
||||
// SELL: 卖出 token,收到 USDC
|
||||
side = "SELL"
|
||||
asset = bestOutId
|
||||
sizeRaw = bestOutVal
|
||||
usdcRaw = usdcIn
|
||||
} else {
|
||||
// 无法判断交易方向
|
||||
return null
|
||||
}
|
||||
|
||||
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
|
||||
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
||||
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
||||
val price = if (size.signum() > 0) {
|
||||
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||
|
||||
// 创建 TradeResponse
|
||||
return TradeResponse(
|
||||
id = txHash,
|
||||
market = marketInfo?.conditionId ?: "",
|
||||
side = side,
|
||||
price = price.toPlainString(),
|
||||
size = size.toPlainString(),
|
||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||
user = walletAddress,
|
||||
outcomeIndex = marketInfo?.outcomeIndex,
|
||||
outcome = marketInfo?.outcome
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
* 使用 Retrofit 接口,支持 clob_token_ids 参数
|
||||
*/
|
||||
suspend fun fetchMarketByTokenId(tokenId: String, retrofitFactory: RetrofitFactory): MarketInfo? {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketsResponse = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
clobTokenIds = listOf(tokenId),
|
||||
includeTag = null
|
||||
)
|
||||
|
||||
if (!marketsResponse.isSuccessful || marketsResponse.body() == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val markets = marketsResponse.body()!!
|
||||
val market = markets.firstOrNull()
|
||||
|
||||
if (market == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析 clobTokenIds(可能是 JSON 字符串或数组)
|
||||
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
|
||||
val clobTokenIds = when {
|
||||
clobTokenIdsRaw == null -> null
|
||||
else -> {
|
||||
// 解析 JSON 字符串
|
||||
parseStringArray(clobTokenIdsRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 outcomes(可能是 JSON 字符串或数组)
|
||||
val outcomes = parseStringArray(market.outcomes)
|
||||
|
||||
// 查找 tokenId 在 clobTokenIds 中的索引
|
||||
val outcomeIndex = clobTokenIds?.indexOfFirst { token ->
|
||||
token.equals(tokenId, ignoreCase = true)
|
||||
}?.takeIf { it >= 0 }
|
||||
|
||||
// 获取 outcome 名称
|
||||
val outcome = if (outcomeIndex != null && outcomes.isNotEmpty() && outcomeIndex < outcomes.size) {
|
||||
outcomes[outcomeIndex]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val conditionId = market.conditionId ?: return null
|
||||
|
||||
MarketInfo(
|
||||
conditionId = conditionId,
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcome = outcome
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询市场信息失败: tokenId=$tokenId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块时间戳
|
||||
*/
|
||||
suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
|
||||
return try {
|
||||
val blockRequest = JsonRpcRequest(
|
||||
method = "eth_getBlockByNumber",
|
||||
params = listOf(blockNumber, false)
|
||||
)
|
||||
|
||||
val blockResponse = rpcApi.call(blockRequest)
|
||||
if (blockResponse.isSuccessful && blockResponse.body() != null) {
|
||||
val blockRpcResponse = blockResponse.body()!!
|
||||
if (blockRpcResponse.error == null && blockRpcResponse.result != null) {
|
||||
val blockJson = blockRpcResponse.result.asJsonObject
|
||||
val timestampHex = blockJson.get("timestamp")?.asString
|
||||
if (timestampHex != null) {
|
||||
BigInteger(timestampHex.removePrefix("0x"), 16).toLong() * 1000 // 转换为毫秒
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取区块时间戳失败: blockNumber=$blockNumber, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:地址转 topic(32字节,左对齐)
|
||||
*/
|
||||
fun addressToTopic32(address: String): String {
|
||||
val clean = address.removePrefix("0x").lowercase()
|
||||
return "0x" + clean.padStart(64, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:topic 转地址
|
||||
*/
|
||||
fun topicToAddress(topic: String): String {
|
||||
val clean = topic.removePrefix("0x").lowercase()
|
||||
return "0x" + clean.takeLast(40)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:十六进制转 BigInteger
|
||||
*/
|
||||
fun hexToBigInt(hex: String): BigInteger {
|
||||
val clean = hex.removePrefix("0x")
|
||||
return if (clean.isBlank()) BigInteger.ZERO else BigInteger(clean, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:十六进制转字节数组
|
||||
*/
|
||||
fun bytesFromHex(hex: String): ByteArray {
|
||||
val clean = hex.removePrefix("0x")
|
||||
return ByteArray(clean.length / 2) { i ->
|
||||
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:从字节数组切片 BigInteger(32字节)
|
||||
*/
|
||||
fun sliceBigInt32(bytes: ByteArray, offset: Int): BigInteger {
|
||||
if (offset + 32 > bytes.size) return BigInteger.ZERO
|
||||
val slice = bytes.sliceArray(offset until offset + 32)
|
||||
return BigInteger(1, slice)
|
||||
}
|
||||
}
|
||||
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.getProxyConfig
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 统一的链上 WebSocket 服务
|
||||
* 管理唯一的 WebSocket 连接,其他服务通过订阅的方式接收链上事件
|
||||
*/
|
||||
@Service
|
||||
class UnifiedOnChainWsService(
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java)
|
||||
|
||||
@Value("\${copy.trading.onchain.ws.reconnect.delay:3000}")
|
||||
private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// WebSocket 连接(唯一)
|
||||
private var webSocket: WebSocket? = null
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
// 订阅ID计数器(用于请求 ID)
|
||||
private var requestIdCounter = 0
|
||||
|
||||
// 连接任务(确保只有一个连接任务在运行)
|
||||
private var connectionJob: Job? = null
|
||||
|
||||
// 存储所有订阅:subscriptionId -> 订阅信息
|
||||
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
|
||||
|
||||
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
|
||||
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
|
||||
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
|
||||
|
||||
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
|
||||
// 用于在收到日志通知时,知道是哪个订阅
|
||||
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* 订阅信息
|
||||
*/
|
||||
data class SubscriptionInfo(
|
||||
val subscriptionId: String, // 订阅的唯一标识
|
||||
val address: String, // 要监听的地址(Leader 地址或账户代理地址)
|
||||
val entityType: String, // 实体类型:LEADER 或 ACCOUNT
|
||||
val entityId: Long, // 实体 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<String?>, subscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val params = mapOf(
|
||||
"address" to address.lowercase(),
|
||||
"topics" to topics.filterNotNull()
|
||||
)
|
||||
|
||||
val requestId = ++requestIdCounter
|
||||
requestIdToSubscriptionId[requestId] = subscriptionId
|
||||
|
||||
val request = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to requestId,
|
||||
"method" to "eth_subscribe",
|
||||
"params" to listOf("logs", params)
|
||||
)
|
||||
|
||||
val message = gson.toJson(request)
|
||||
ws.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 RPC 订阅
|
||||
*/
|
||||
private fun unsubscribeRpc(rpcSubscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val requestId = ++requestIdCounter
|
||||
val request = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to requestId,
|
||||
"method" to "eth_unsubscribe",
|
||||
"params" to listOf(rpcSubscriptionId)
|
||||
)
|
||||
|
||||
val message = gson.toJson(request)
|
||||
ws.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息
|
||||
*/
|
||||
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
val message = gson.fromJson(text, JsonObject::class.java)
|
||||
|
||||
// 处理订阅响应
|
||||
if (message.has("result") && message.has("id")) {
|
||||
val requestId = message.get("id")?.asInt
|
||||
val rpcSubscriptionId = message.get("result")?.asString
|
||||
|
||||
if (requestId != null && rpcSubscriptionId != null) {
|
||||
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
|
||||
if (subscriptionId != null) {
|
||||
// 保存 RPC subscriptionId 到订阅的映射
|
||||
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
|
||||
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 处理日志通知
|
||||
if (message.has("params")) {
|
||||
val params = message.getAsJsonObject("params")
|
||||
val subscriptionIdParam = params.get("subscription")?.asString
|
||||
val result = params.getAsJsonObject("result")
|
||||
|
||||
if (result != null) {
|
||||
val txHash = result.get("transactionHash")?.asString
|
||||
if (txHash != null && subscriptionIdParam != null) {
|
||||
// 根据 RPC subscriptionId 找到对应的订阅
|
||||
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
|
||||
if (subscriptionId != null) {
|
||||
// 处理交易,分发给对应的订阅者
|
||||
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
|
||||
} else {
|
||||
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
|
||||
processTransaction(txHash, httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易(为特定订阅)
|
||||
* 直接调用订阅的回调
|
||||
*/
|
||||
private suspend fun processTransactionForSubscription(
|
||||
txHash: String,
|
||||
subscriptionId: String,
|
||||
httpClient: OkHttpClient,
|
||||
rpcApi: EthereumRpcApi
|
||||
) {
|
||||
val subscription = subscriptions[subscriptionId] ?: return
|
||||
|
||||
try {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
} catch (e: Exception) {
|
||||
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易(为所有订阅,用于兼容)
|
||||
* 解析交易中的 Transfer 事件,分发给所有订阅者
|
||||
*/
|
||||
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 为每个订阅检查是否匹配,如果匹配则调用回调
|
||||
for (subscription in subscriptions.values) {
|
||||
val address = subscription.address
|
||||
|
||||
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
|
||||
val isInvolved = erc20Transfers.any {
|
||||
it.from.lowercase() == address || it.to.lowercase() == address
|
||||
} || erc1155Transfers.any {
|
||||
it.from.lowercase() == address || it.to.lowercase() == address
|
||||
}
|
||||
|
||||
if (isInvolved) {
|
||||
// 该地址参与了交易,调用回调
|
||||
try {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
} catch (e: Exception) {
|
||||
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止连接
|
||||
*/
|
||||
fun stop() {
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
|
||||
// 关闭 WebSocket 连接
|
||||
webSocket?.close(1000, "停止监听")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
|
||||
// 清空订阅信息
|
||||
subscriptions.clear()
|
||||
requestIdToSubscriptionId.clear()
|
||||
rpcSubscriptionIdToSubscriptionId.clear()
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 服务启动时不自动连接,等待有订阅时再连接
|
||||
logger.info("统一链上 WebSocket 服务已初始化")
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-11
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.orders
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
import com.wrbug.polymarketbot.dto.OrderDetailDto
|
||||
import com.wrbug.polymarketbot.dto.OrderMessageDto
|
||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||
@@ -13,6 +14,11 @@ import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -26,10 +32,10 @@ class OrderPushService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证)
|
||||
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
|
||||
private val copyOrderTrackingRepository: com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
|
||||
private val copyTradingRepository: com.wrbug.polymarketbot.repository.CopyTradingRepository? = null, // 可选,避免循环依赖
|
||||
private val leaderRepository: com.wrbug.polymarketbot.repository.LeaderRepository? = null // 可选,避免循环依赖
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
|
||||
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
|
||||
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||
@@ -152,9 +158,9 @@ class OrderPushService(
|
||||
return account.apiKey != null &&
|
||||
account.apiSecret != null &&
|
||||
account.apiPassphrase != null &&
|
||||
account.apiKey!!.isNotBlank() &&
|
||||
account.apiSecret!!.isNotBlank() &&
|
||||
account.apiPassphrase!!.isNotBlank()
|
||||
account.apiKey.isNotBlank() &&
|
||||
account.apiSecret.isNotBlank() &&
|
||||
account.apiPassphrase.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +384,7 @@ class OrderPushService(
|
||||
|
||||
/**
|
||||
* 获取订单详情
|
||||
* 通过 PolymarketClobService 获取订单详情
|
||||
* 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情
|
||||
*/
|
||||
private suspend fun fetchOrderDetail(
|
||||
account: Account,
|
||||
@@ -391,7 +397,7 @@ class OrderPushService(
|
||||
return null
|
||||
}
|
||||
|
||||
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证)
|
||||
// 通过 com.wrbug.polymarketbot.service.common.PolymarketClobService 获取订单详情(需要 L2 认证)
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
decryptApiSecret(account)
|
||||
@@ -453,7 +459,7 @@ class OrderPushService(
|
||||
* 使用 /markets 接口,通过 condition_ids 查询参数获取市场信息
|
||||
* 订单返回的 market 字段是 16 进制的 condition ID(如 "0x...")
|
||||
*/
|
||||
private suspend fun fetchMarketInfo(conditionId: String): com.wrbug.polymarketbot.api.MarketResponse? {
|
||||
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
|
||||
return try {
|
||||
// 创建 Gamma API 客户端(公开 API,不需要认证)
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
+18
-32
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.orders
|
||||
|
||||
import com.wrbug.polymarketbot.api.SignedOrderObject
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
@@ -77,10 +77,6 @@ class OrderSigningService {
|
||||
): OrderAmounts {
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
|
||||
// 舍入价格
|
||||
val roundedPrice = roundNormal(priceDecimal, roundConfig.price)
|
||||
|
||||
if (side.uppercase() == "BUY") {
|
||||
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
||||
// makerAmount 是 USDC 金额,最多 2 位小数
|
||||
@@ -104,8 +100,9 @@ class OrderSigningService {
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
} else {
|
||||
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
|
||||
// makerAmount 是 shares 数量,最多 4 位小数
|
||||
// takerAmount 是 USDC 金额,需要使用原始价格计算(与SDK保持一致)
|
||||
// 根据 Polymarket API 要求:
|
||||
// - makerAmount (shares) 最多 2 位小数
|
||||
// - takerAmount (USDC) 最多 4 位小数
|
||||
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
|
||||
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
|
||||
@@ -113,16 +110,15 @@ class OrderSigningService {
|
||||
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
|
||||
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
|
||||
|
||||
// 确保 makerAmount 精度(shares,最多 4 位小数)
|
||||
val finalMakerAmt = roundDown(rawMakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
|
||||
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// takerAmount 不进行舍入,直接使用精确计算结果转换为 wei
|
||||
// parseUnits 会将 BigDecimal 转换为 wei(6 位小数),自动处理精度
|
||||
// 使用原始价格计算可以确保与SDK的结果一致
|
||||
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
|
||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 转换为 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())
|
||||
}
|
||||
@@ -176,25 +172,15 @@ class OrderSigningService {
|
||||
// 5. 确保 maker 地址也是小写格式
|
||||
val makerAddressLower = makerAddress.lowercase()
|
||||
|
||||
// 打印签名前的订单参数
|
||||
logger.info("========== 订单签名前参数 ==========")
|
||||
logger.info("订单方向: $side")
|
||||
logger.info("价格: $price")
|
||||
logger.info("数量: $size")
|
||||
logger.info("Token ID: $tokenId")
|
||||
logger.info("Maker 地址: $makerAddressLower")
|
||||
logger.info("Signer 地址: $signerAddress")
|
||||
logger.info("Taker 地址: $taker")
|
||||
logger.info("Maker Amount (wei): ${amounts.makerAmount}")
|
||||
logger.info("Taker Amount (wei): ${amounts.takerAmount}")
|
||||
logger.info("Salt: $salt")
|
||||
logger.info("Expiration: $expiration")
|
||||
logger.info("Nonce: $nonce")
|
||||
logger.info("Fee Rate BPS: $feeRateBps")
|
||||
logger.info("Signature Type: $signatureType")
|
||||
logger.info("Exchange Contract: $EXCHANGE_CONTRACT")
|
||||
logger.info("Chain ID: $CHAIN_ID")
|
||||
logger.info("====================================")
|
||||
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露)
|
||||
logger.debug("========== 订单签名前参数 ==========")
|
||||
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
|
||||
logger.debug("Token ID: $tokenId")
|
||||
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
|
||||
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
|
||||
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
val signature = signOrder(
|
||||
+660
-362
File diff suppressed because it is too large
Load Diff
+93
-19
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
@@ -13,6 +13,8 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
@@ -30,7 +32,7 @@ class CopyTradingStatisticsService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountService: AccountService,
|
||||
private val blockchainService: com.wrbug.polymarketbot.service.BlockchainService
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
|
||||
@@ -69,7 +71,10 @@ class CopyTradingStatisticsService(
|
||||
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity)
|
||||
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 9. 构建响应
|
||||
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
|
||||
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 11. 构建响应
|
||||
val response = CopyTradingStatisticsResponse(
|
||||
copyTradingId = copyTradingId,
|
||||
accountId = copyTrading.accountId,
|
||||
@@ -85,7 +90,7 @@ class CopyTradingStatisticsService(
|
||||
totalSellOrders = statistics.totalSellOrders,
|
||||
totalSellAmount = statistics.totalSellAmount,
|
||||
currentPositionQuantity = statistics.currentPositionQuantity,
|
||||
currentPositionValue = calculatePositionValue(statistics.currentPositionQuantity, currentPrice),
|
||||
currentPositionValue = positionValue,
|
||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||
totalUnrealizedPnl = unrealizedPnl,
|
||||
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
|
||||
@@ -105,7 +110,7 @@ class CopyTradingStatisticsService(
|
||||
fun getOrderList(request: OrderTrackingRequest): Result<OrderListResponse> {
|
||||
return try {
|
||||
// 1. 验证跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||
|
||||
// 2. 根据类型查询
|
||||
@@ -293,8 +298,10 @@ class CopyTradingStatisticsService(
|
||||
}
|
||||
|
||||
// 卖出统计
|
||||
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
|
||||
// 因为每个明细都记录了准确的匹配数量和卖出价格
|
||||
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
|
||||
val totalSellAmount = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellOrders = sellRecords.size.toLong()
|
||||
|
||||
// 持仓统计
|
||||
@@ -334,9 +341,9 @@ class CopyTradingStatisticsService(
|
||||
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
|
||||
val result = accountService.getMarketPrice(marketId, outcomeIndex)
|
||||
result.onSuccess { response ->
|
||||
// 使用中间价,如果没有则使用最后价格
|
||||
val price = response.midpoint ?: response.lastPrice
|
||||
if (price != null) {
|
||||
// 使用当前价格
|
||||
val price = response.currentPrice
|
||||
if (price.isNotBlank() && price != "0") {
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "$marketId:$outcomeIndex"
|
||||
prices[key] = price
|
||||
@@ -354,7 +361,7 @@ class CopyTradingStatisticsService(
|
||||
* 获取链上实际持仓
|
||||
* 按 (marketId, outcomeIndex) 组合返回实际持仓数量
|
||||
*/
|
||||
private suspend fun getActualPositions(account: com.wrbug.polymarketbot.entity.Account?): Map<String, BigDecimal> {
|
||||
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
|
||||
val positions = mutableMapOf<String, BigDecimal>()
|
||||
|
||||
if (account == null || account.proxyAddress.isBlank()) {
|
||||
@@ -385,6 +392,7 @@ class CopyTradingStatisticsService(
|
||||
/**
|
||||
* 计算未实现盈亏
|
||||
* 使用链上实际持仓数量,而不是 remainingQuantity(考虑手动卖出的情况)
|
||||
* 按市场聚合订单,计算加权平均买入价格,避免重复计算
|
||||
*/
|
||||
private fun calculateUnrealizedPnl(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
@@ -393,6 +401,9 @@ class CopyTradingStatisticsService(
|
||||
): String {
|
||||
var totalUnrealizedPnl = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合订单,计算加权平均买入价格
|
||||
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
|
||||
|
||||
for (order in buyOrders) {
|
||||
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
|
||||
if (order.outcomeIndex == null) {
|
||||
@@ -403,8 +414,32 @@ class CopyTradingStatisticsService(
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "${order.marketId}:${order.outcomeIndex}"
|
||||
|
||||
// 获取链上实际持仓数量(如果存在),否则使用 remainingQuantity
|
||||
val actualQty = actualPositions[key] ?: order.remainingQuantity.toSafeBigDecimal()
|
||||
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
|
||||
val orderQty = order.remainingQuantity.toSafeBigDecimal()
|
||||
|
||||
// 如果订单持仓 <= 0,跳过
|
||||
if (orderQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
val orderCost = orderQty.multi(buyPrice)
|
||||
|
||||
// 聚合同一市场的订单
|
||||
val existing = marketAggregates[key]
|
||||
if (existing != null) {
|
||||
val totalQty = existing.first.add(orderQty)
|
||||
val totalCost = existing.second.add(orderCost)
|
||||
marketAggregates[key] = Pair(totalQty, totalCost)
|
||||
} else {
|
||||
marketAggregates[key] = Pair(orderQty, orderCost)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算每个市场的未实现盈亏
|
||||
for ((key, aggregate) in marketAggregates) {
|
||||
val (totalQty, totalCost) = aggregate
|
||||
|
||||
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
|
||||
val actualQty = actualPositions[key] ?: totalQty
|
||||
|
||||
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
|
||||
if (actualQty.lte(BigDecimal.ZERO)) continue
|
||||
@@ -413,9 +448,15 @@ class CopyTradingStatisticsService(
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
// 使用实际持仓数量计算未实现盈亏
|
||||
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(actualQty)
|
||||
// 计算加权平均买入价格
|
||||
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
|
||||
totalCost.div(totalQty)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
|
||||
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
|
||||
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
|
||||
}
|
||||
|
||||
@@ -424,11 +465,44 @@ class CopyTradingStatisticsService(
|
||||
|
||||
/**
|
||||
* 计算持仓价值
|
||||
* 使用链上实际持仓数量和当前市场价格计算
|
||||
* 按市场聚合,避免重复计算
|
||||
*/
|
||||
private fun calculatePositionValue(positionQuantity: String, currentPrices: Map<String, String>): String {
|
||||
// 这里简化处理,实际应该根据每个市场的持仓分别计算
|
||||
// 暂时返回0,因为需要知道每个市场的持仓数量
|
||||
return "0"
|
||||
private fun calculatePositionValue(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
currentPrices: Map<String, String>,
|
||||
actualPositions: Map<String, BigDecimal>
|
||||
): String {
|
||||
var totalPositionValue = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合,获取所有不同的市场
|
||||
val marketKeys = buyOrders
|
||||
.filter { it.outcomeIndex != null }
|
||||
.map { "${it.marketId}:${it.outcomeIndex}" }
|
||||
.distinct()
|
||||
|
||||
for (key in marketKeys) {
|
||||
// 获取链上实际持仓数量(如果存在)
|
||||
val actualQty = actualPositions[key]
|
||||
|
||||
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
|
||||
val totalQty = actualQty ?: buyOrders
|
||||
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
|
||||
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
|
||||
// 如果持仓 <= 0,跳过
|
||||
if (totalQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
// 获取当前市场价格
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
// 计算持仓价值:持仓数量 × 当前价格
|
||||
val positionValue = totalQty.multi(currentPrice)
|
||||
totalPositionValue = totalPositionValue.add(positionValue)
|
||||
}
|
||||
|
||||
return totalPositionValue.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
+887
@@ -0,0 +1,887 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.repository.*
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.context.i18n.LocaleContextHolder
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 订单状态更新服务
|
||||
* 定时轮询更新卖出订单的实际成交价,并更新买入订单的实际数据并发送通知
|
||||
*/
|
||||
@Service
|
||||
class OrderStatusUpdateService(
|
||||
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||
|
||||
private val updateScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
logger.info("订单状态更新服务已启动,将每5秒轮询一次")
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时更新订单状态
|
||||
* 每5秒执行一次
|
||||
*/
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun updateOrderStatus() {
|
||||
updateScope.launch {
|
||||
try {
|
||||
// 1. 清理已删除账户的订单
|
||||
cleanupDeletedAccountOrders()
|
||||
|
||||
// 2. 检查30秒前创建的订单,如果未成交则删除
|
||||
checkAndDeleteUnfilledOrders()
|
||||
|
||||
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
|
||||
updatePendingSellOrderPrices()
|
||||
|
||||
// 4. 更新买入订单的实际数据并发送通知
|
||||
updatePendingBuyOrders()
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单状态更新异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证订单ID格式
|
||||
* 订单ID必须以 0x 开头,且是有效的 16 进制字符串
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @return 如果格式有效返回 true,否则返回 false
|
||||
*/
|
||||
private fun isValidOrderId(orderId: String): Boolean {
|
||||
if (!orderId.startsWith("0x", ignoreCase = true)) {
|
||||
return false
|
||||
}
|
||||
// 验证是否为有效的 16 进制字符串(去除 0x 前缀后)
|
||||
val hexPart = orderId.substring(2)
|
||||
if (hexPart.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
// 检查是否只包含 0-9, a-f, A-F
|
||||
return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已删除账户的订单
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun cleanupDeletedAccountOrders() {
|
||||
try {
|
||||
// 查询所有卖出记录
|
||||
val allRecords = sellMatchRecordRepository.findAll()
|
||||
|
||||
// 查询所有有效的账户ID
|
||||
val validAccountIds = accountRepository.findAll().mapNotNull { it.id }.toSet()
|
||||
|
||||
// 查询所有有效的跟单关系
|
||||
val validCopyTradingIds = copyTradingRepository.findAll()
|
||||
.filter { it.accountId in validAccountIds }
|
||||
.mapNotNull { it.id }
|
||||
.toSet()
|
||||
|
||||
// 找出需要删除的记录(关联的跟单关系已不存在或账户已删除)
|
||||
val recordsToDelete = allRecords.filter { record ->
|
||||
val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
copyTrading == null || copyTrading.accountId !in validAccountIds
|
||||
}
|
||||
|
||||
if (recordsToDelete.isNotEmpty()) {
|
||||
logger.info("清理已删除账户的订单: ${recordsToDelete.size} 条记录")
|
||||
|
||||
// 删除匹配明细
|
||||
for (record in recordsToDelete) {
|
||||
val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!)
|
||||
sellMatchDetailRepository.deleteAll(details)
|
||||
}
|
||||
|
||||
// 删除卖出记录
|
||||
sellMatchRecordRepository.deleteAll(recordsToDelete)
|
||||
|
||||
logger.info("已清理 ${recordsToDelete.size} 条已删除账户的订单记录")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理已删除账户订单异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查30秒前创建的订单,如果未成交则删除
|
||||
* 首次检测但加入缓存中30s后还没有成交,则删除
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun checkAndDeleteUnfilledOrders() {
|
||||
try {
|
||||
// 计算30秒前的时间戳
|
||||
val thirtySecondsAgo = System.currentTimeMillis() - 30000
|
||||
|
||||
// 查询30秒前创建的订单
|
||||
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
|
||||
|
||||
if (ordersToCheck.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
|
||||
|
||||
// 按账户分组,避免重复创建 API 客户端
|
||||
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
|
||||
|
||||
for ((accountId, orders) in ordersByAccount) {
|
||||
try {
|
||||
// 获取账户
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn("账户不存在,跳过检查: accountId=$accountId")
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查账户是否配置了 API 凭证
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过检查: accountId=${account.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建带认证的 CLOB API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 检查每个订单
|
||||
for (order in orders) {
|
||||
try {
|
||||
// 查询订单详情
|
||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||
|
||||
// 先检查 HTTP 状态码,非 200 的都跳过
|
||||
if (orderResponse.code() != 200) {
|
||||
// HTTP 非 200,记录日志并跳过,等待下次轮询
|
||||
// 不删除订单,因为可能是临时网络问题或 API 错误
|
||||
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
|
||||
logger.debug("订单查询失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||
continue
|
||||
}
|
||||
|
||||
// HTTP 200,检查响应体
|
||||
// 响应体也可能返回字符串 "null",Gson 解析时会返回 null
|
||||
val orderDetail = orderResponse.body()
|
||||
if (orderDetail == null) {
|
||||
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
|
||||
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
|
||||
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 订单不存在且未部分卖出,删除本地订单
|
||||
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查订单是否成交
|
||||
// 如果订单状态不是 FILLED 且已成交数量为0,说明未成交,删除
|
||||
val sizeMatched = orderDetail.sizeMatched?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
if (orderDetail.status != "FILLED" && sizeMatched <= BigDecimal.ZERO) {
|
||||
logger.info("订单30秒后仍未成交,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除未成交订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除未成交订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
logger.debug("订单已成交或部分成交,保留: orderId=${order.buyOrderId}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户订单失败: accountId=$accountId, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查未成交订单异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新待更新的卖出订单价格
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingSellOrderPrices() {
|
||||
try {
|
||||
// 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理)
|
||||
val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse()
|
||||
|
||||
if (pendingRecords.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${pendingRecords.size} 条待更新价格的卖出订单")
|
||||
|
||||
for (record in pendingRecords) {
|
||||
try {
|
||||
// 获取跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
if (copyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${record.copyTradingId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
val account = accountRepository.findById(copyTrading.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn("账户不存在,跳过更新: accountId=${copyTrading.accountId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查账户是否配置了 API 凭证
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建带认证的 CLOB API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
|
||||
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
|
||||
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
|
||||
|
||||
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则不发送通知
|
||||
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
|
||||
|
||||
if (!isAutoOrder) {
|
||||
// 非自动订单,发送通知(使用临时数据)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
useTemporaryData = true,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
} else {
|
||||
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
|
||||
}
|
||||
|
||||
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则跳过发送通知
|
||||
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
|
||||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
|
||||
|
||||
if (isAutoOrder) {
|
||||
logger.debug("自动生成的订单,跳过发送通知并直接标记为已处理: orderId=${record.sellOrderId}")
|
||||
// 直接标记为已处理,不发送通知
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已处理
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
continue
|
||||
}
|
||||
|
||||
// 查询订单详情,获取实际成交价
|
||||
val actualSellPrice = trackingService.getActualExecutionPrice(
|
||||
orderId = record.sellOrderId,
|
||||
clobApi = clobApi,
|
||||
fallbackPrice = record.sellPrice
|
||||
)
|
||||
|
||||
// 如果价格已更新(与当前价格不同),更新数据库
|
||||
if (actualSellPrice != record.sellPrice) {
|
||||
// 重新计算盈亏
|
||||
val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!)
|
||||
var totalRealizedPnl = BigDecimal.ZERO
|
||||
|
||||
for (detail in details) {
|
||||
val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity)
|
||||
|
||||
// 更新明细的卖出价格和盈亏
|
||||
// 注意:SellMatchDetail 的字段都是 val,需要创建新对象
|
||||
val updatedDetail = SellMatchDetail(
|
||||
id = detail.id,
|
||||
matchRecordId = detail.matchRecordId,
|
||||
trackingId = detail.trackingId,
|
||||
buyOrderId = detail.buyOrderId,
|
||||
matchedQuantity = detail.matchedQuantity,
|
||||
buyPrice = detail.buyPrice,
|
||||
sellPrice = actualSellPrice, // 更新卖出价格
|
||||
realizedPnl = updatedRealizedPnl, // 更新盈亏
|
||||
createdAt = detail.createdAt
|
||||
)
|
||||
sellMatchDetailRepository.save(updatedDetail)
|
||||
|
||||
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
|
||||
}
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
// 更新卖出记录
|
||||
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = actualSellPrice, // 更新卖出价格
|
||||
totalRealizedPnl = totalRealizedPnl, // 更新总盈亏
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
|
||||
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
} else {
|
||||
// 价格相同,但已经查询过,发送通知并标记为已处理
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
sellOrderId = record.sellOrderId,
|
||||
leaderSellTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
side = record.side,
|
||||
outcomeIndex = record.outcomeIndex,
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新待更新卖出订单价格异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新待发送通知的买入订单
|
||||
* 查询订单详情获取实际价格和数量,然后发送通知并更新数据库
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingBuyOrders() {
|
||||
try {
|
||||
// 查询所有未发送通知的买入订单
|
||||
val pendingOrders = copyOrderTrackingRepository.findByNotificationSentFalse()
|
||||
|
||||
if (pendingOrders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${pendingOrders.size} 条待发送通知的买入订单")
|
||||
|
||||
for (order in pendingOrders) {
|
||||
try {
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(order.buyOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
|
||||
// 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(order.copyTradingId).orElse(null)
|
||||
if (copyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${order.copyTradingId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
val account = accountRepository.findById(order.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn("账户不存在,跳过更新: accountId=${order.accountId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查账户是否配置了 API 凭证
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过更新: accountId=${account.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase!!)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建带认证的 CLOB API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 查询订单详情
|
||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||
|
||||
// 先检查 HTTP 状态码,非 200 的都跳过
|
||||
if (orderResponse.code() != 200) {
|
||||
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
|
||||
logger.debug("查询订单详情失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||
continue
|
||||
}
|
||||
|
||||
// HTTP 200,检查响应体
|
||||
// 响应体也可能返回字符串 "null",Gson 解析时会返回 null
|
||||
val orderDetail = orderResponse.body()
|
||||
if (orderDetail == null) {
|
||||
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
|
||||
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
|
||||
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 订单不存在且未部分卖出,删除本地订单
|
||||
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取实际价格和数量
|
||||
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
|
||||
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
|
||||
val actualOutcome = orderDetail.outcome
|
||||
|
||||
// 更新订单数据(如果实际数据与临时数据不同)
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
|
||||
// 创建更新后的订单对象
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = actualSize, // 使用实际数量
|
||||
price = actualPrice, // 使用实际价格
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// 保存更新后的订单
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
|
||||
if (needUpdate) {
|
||||
logger.info("更新买入订单数据成功: orderId=${order.buyOrderId}, 原价格=${order.price}, 新价格=$actualPrice, 原数量=${order.quantity}, 新数量=$actualSize")
|
||||
} else {
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新待发送通知买入订单异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送买入订单通知
|
||||
*/
|
||||
private suspend fun sendBuyOrderNotification(
|
||||
order: CopyOrderTracking,
|
||||
useTemporaryData: Boolean = false,
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取跟单关系和账户信息(如果未提供)
|
||||
val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(order.copyTradingId).orElse(null)
|
||||
if (finalCopyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${order.copyTradingId}")
|
||||
return
|
||||
}
|
||||
|
||||
val finalAccount = account ?: accountRepository.findById(order.accountId).orElse(null)
|
||||
if (finalAccount == null) {
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: order.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(order.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = finalCopyTrading.configName
|
||||
|
||||
// 获取当前语言设置
|
||||
val locale = try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 创建 CLOB API 客户端(如果未提供)
|
||||
val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
finalAccount.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
finalAccount.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = order.buyOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
walletAddress = finalAccount.walletAddress,
|
||||
clobApi = finalClobApi,
|
||||
apiKey = finalAccount.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送买入订单通知失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送卖出订单通知
|
||||
*/
|
||||
private suspend fun sendSellOrderNotification(
|
||||
record: SellMatchRecord,
|
||||
useTemporaryData: Boolean = false,
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取跟单关系和账户信息(如果未提供)
|
||||
val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
if (finalCopyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${record.copyTradingId}")
|
||||
return
|
||||
}
|
||||
|
||||
val finalAccount = account ?: accountRepository.findById(finalCopyTrading.accountId).orElse(null)
|
||||
if (finalAccount == null) {
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: record.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = finalCopyTrading.configName
|
||||
|
||||
// 获取当前语言设置
|
||||
val locale = try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 创建 CLOB API 客户端(如果未提供)
|
||||
val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
finalAccount.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
finalAccount.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = record.sellOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = record.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "SELL",
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
walletAddress = finalAccount.walletAddress,
|
||||
clobApi = finalClobApi,
|
||||
apiKey = finalAccount.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送卖出订单通知失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.copytrading.templates
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CopyTradingTemplate
|
||||
@@ -61,7 +61,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = request.supportSell ?: true,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
@@ -120,7 +119,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = request.supportSell ?: template.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
@@ -187,7 +185,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = request.supportSell ?: sourceTemplate.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
||||
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
|
||||
)
|
||||
@@ -261,7 +258,6 @@ class CopyTradingTemplateService(
|
||||
supportSell = template.supportSell,
|
||||
minOrderDepth = template.minOrderDepth?.toPlainString(),
|
||||
maxSpread = template.maxSpread?.toPlainString(),
|
||||
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
|
||||
minPrice = template.minPrice?.toPlainString(),
|
||||
maxPrice = template.maxPrice?.toPlainString(),
|
||||
createdAt = template.createdAt,
|
||||
+9
-15
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||
@@ -12,6 +12,8 @@ import org.springframework.beans.BeansException
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -26,12 +28,11 @@ class ApiHealthCheckService(
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private val polymarketWsUrl: String,
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String
|
||||
private val builderRelayerUrl: String,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
@@ -184,19 +185,12 @@ class ApiHealthCheckService(
|
||||
|
||||
/**
|
||||
* 检查 Polygon RPC
|
||||
* 使用动态获取的可用节点(RpcNodeService 总是返回一个有效的 URL,包括默认节点)
|
||||
*/
|
||||
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Polygon RPC",
|
||||
url = "未配置",
|
||||
status = "skipped",
|
||||
message = "未配置 Polygon RPC URL"
|
||||
)
|
||||
}
|
||||
|
||||
val url = polygonRpcUrl
|
||||
checkJsonRpcApi("Polygon RPC", url)
|
||||
// 使用 RpcNodeService 获取可用节点(总是返回有效值,包括默认节点)
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.ProxyConfig
|
||||
import com.wrbug.polymarketbot.repository.ProxyConfigRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.util.ProxyConfigProvider
|
||||
import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier
|
||||
import com.wrbug.polymarketbot.util.createSSLSocketFactory
|
||||
+18
-39
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
@@ -24,12 +24,11 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class RelayClientService(
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val systemConfigService: SystemConfigService
|
||||
private val systemConfigService: SystemConfigService,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RelayClientService::class.java)
|
||||
@@ -43,12 +42,9 @@ class RelayClientService(
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi? by lazy {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(polygonRpcUrl)
|
||||
}
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,7 +256,7 @@ class RelayClientService(
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
apiKey = builderApiKey,
|
||||
@@ -337,15 +333,10 @@ class RelayClientService(
|
||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
val packedSignature = splitAndPackSig(safeSignature)
|
||||
|
||||
// 调试日志
|
||||
// 调试日志(地址已遮蔽)
|
||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||
logger.debug("Safe Address: $proxyAddress")
|
||||
logger.debug("From Address: $fromAddress")
|
||||
logger.debug("To: ${safeTx.to}")
|
||||
logger.debug("Data: $redeemCallData")
|
||||
logger.debug("Nonce: $proxyNonce")
|
||||
logger.debug("Packed Signature: $packedSignature")
|
||||
logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)")
|
||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||
logger.debug("Signature Length: ${packedSignature.length}")
|
||||
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
@@ -368,13 +359,7 @@ class RelayClientService(
|
||||
metadata = "Redeem positions via Builder Relayer"
|
||||
)
|
||||
|
||||
logger.debug("Request Type: ${request.type}")
|
||||
logger.debug("Request From: ${request.from}")
|
||||
logger.debug("Request To: ${request.to}")
|
||||
logger.debug("Request ProxyWallet: ${request.proxyWallet}")
|
||||
logger.debug("Request Data Length: ${request.data.length}")
|
||||
logger.debug("Request Signature Length: ${request.signature.length}")
|
||||
logger.debug("Request Nonce: ${request.nonce}")
|
||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
@@ -461,13 +446,7 @@ class RelayClientService(
|
||||
safeTx: SafeTransaction
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法执行交易")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -802,10 +781,10 @@ class RelayClientService(
|
||||
safeTxs: List<SafeTransaction>
|
||||
): Result<String> {
|
||||
// 批量执行:将多个交易合并为一个 execTransaction 调用
|
||||
// 当前实现:委托给 BlockchainService
|
||||
// 当前实现:委托给 com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
return Result.failure(
|
||||
UnsupportedOperationException(
|
||||
"批量 Gasless 执行暂未实现。请使用 BlockchainService.redeemPositions() 方法。"
|
||||
"批量 Gasless 执行暂未实现。请使用 com.wrbug.polymarketbot.service.common.BlockchainService.redeemPositions() 方法。"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.entity.NodeHealthStatus
|
||||
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||
import com.wrbug.polymarketbot.entity.RpcProviderType
|
||||
import com.wrbug.polymarketbot.repository.RpcNodeConfigRepository
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* RPC 节点管理服务
|
||||
* 负责管理用户配置的 Polygon RPC 节点
|
||||
*/
|
||||
@Service
|
||||
class RpcNodeService(
|
||||
private val rpcNodeConfigRepository: RpcNodeConfigRepository,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RpcNodeService::class.java)
|
||||
|
||||
companion object {
|
||||
// 默认公共节点
|
||||
private const val DEFAULT_RPC_URL = "https://polygon.publicnode.com"
|
||||
private const val DEFAULT_WS_URL = "wss://polygon.publicnode.com"
|
||||
|
||||
// 主流服务商 URL 模板
|
||||
private val PROVIDER_HTTP_TEMPLATES = mapOf(
|
||||
RpcProviderType.ALCHEMY to "https://polygon-mainnet.g.alchemy.com/v2/{apiKey}",
|
||||
RpcProviderType.INFURA to "https://polygon-mainnet.infura.io/v3/{apiKey}",
|
||||
RpcProviderType.QUICKNODE to "https://your-endpoint.quiknode.pro/{apiKey}/",
|
||||
RpcProviderType.CHAINSTACK to "https://polygon-mainnet.core.chainstack.com/{apiKey}",
|
||||
RpcProviderType.GETBLOCK to "https://go.getblock.io/{apiKey}/"
|
||||
)
|
||||
|
||||
private val PROVIDER_WS_TEMPLATES = mapOf(
|
||||
RpcProviderType.ALCHEMY to "wss://polygon-mainnet.g.alchemy.com/v2/{apiKey}",
|
||||
RpcProviderType.INFURA to "wss://polygon-mainnet.infura.io/ws/v3/{apiKey}",
|
||||
RpcProviderType.QUICKNODE to "wss://your-endpoint.quiknode.pro/{apiKey}/",
|
||||
RpcProviderType.CHAINSTACK to "wss://ws-polygon-mainnet.core.chainstack.com/{apiKey}",
|
||||
RpcProviderType.GETBLOCK to "wss://go.getblock.io/{apiKey}/"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有节点配置(不包含默认节点)
|
||||
* 默认节点作为兜底,不应该返回给前端
|
||||
*/
|
||||
fun getAllNodes(): List<RpcNodeConfig> {
|
||||
val allNodes = rpcNodeConfigRepository.findAllByOrderByPriorityAsc()
|
||||
|
||||
// 过滤掉默认节点,只返回用户配置的节点
|
||||
return allNodes.filterNot { isDefaultNode(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有节点配置(包含默认节点,用于内部使用)
|
||||
* 只返回启用的节点,禁用的节点会被忽略
|
||||
* 默认节点始终排在最后
|
||||
*/
|
||||
fun getAllNodesWithDefault(): List<RpcNodeConfig> {
|
||||
// 只查询启用的节点
|
||||
val allNodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc()
|
||||
|
||||
// 分离默认节点和用户配置的节点
|
||||
val (defaultNodes, userNodes) = allNodes.partition { isDefaultNode(it) }
|
||||
|
||||
// 返回用户配置的节点,默认节点排在最后(如果存在)
|
||||
return userNodes + defaultNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是默认节点
|
||||
*/
|
||||
private fun isDefaultNode(node: RpcNodeConfig): Boolean {
|
||||
return node.httpUrl == DEFAULT_RPC_URL ||
|
||||
node.httpUrl == DEFAULT_RPC_URL.removeSuffix("/") ||
|
||||
(node.providerType == RpcProviderType.PUBLIC.name &&
|
||||
(node.httpUrl.contains("polygon.publicnode.com") ||
|
||||
node.httpUrl.contains("publicnode.com")))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个可用的节点
|
||||
* 按优先级顺序遍历所有启用的节点,找到第一个真正可用的节点
|
||||
* 如果所有节点都不可用,返回默认节点
|
||||
* @return 可用节点的配置,如果没有可用节点则返回失败
|
||||
*/
|
||||
fun getAvailableNode(): Result<RpcNodeConfig> {
|
||||
return try {
|
||||
val nodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc()
|
||||
.filterNot { isDefaultNode(it) } // 排除默认节点
|
||||
|
||||
if (nodes.isEmpty()) {
|
||||
logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
|
||||
return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
|
||||
}
|
||||
|
||||
// 优先使用最近检查状态为 HEALTHY 的节点
|
||||
val healthyNodes = nodes.filter {
|
||||
it.lastCheckStatus == NodeHealthStatus.HEALTHY.name
|
||||
}
|
||||
|
||||
// 先尝试使用健康的节点(按优先级排序)
|
||||
for (node in healthyNodes) {
|
||||
try {
|
||||
// 快速验证节点是否仍然可用(使用较短的超时时间)
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull()
|
||||
if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) {
|
||||
logger.debug("使用健康的 RPC 节点: ${node.name} (${node.httpUrl})")
|
||||
return Result.success(node)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}")
|
||||
// 继续尝试下一个节点
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有健康的节点,尝试验证所有节点(按优先级)
|
||||
for (node in nodes) {
|
||||
try {
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull()
|
||||
if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) {
|
||||
logger.info("找到可用的 RPC 节点: ${node.name} (${node.httpUrl})")
|
||||
return Result.success(node)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}")
|
||||
// 继续尝试下一个节点
|
||||
}
|
||||
}
|
||||
|
||||
// 所有节点都不可用,返回失败
|
||||
logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
|
||||
Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取可用节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
|
||||
*/
|
||||
fun getHttpUrl(): String {
|
||||
val nodeResult = getAvailableNode()
|
||||
return if (nodeResult.isSuccess) {
|
||||
nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
|
||||
} else {
|
||||
logger.warn("没有可用的用户配置节点,使用默认节点")
|
||||
DEFAULT_RPC_URL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 WebSocket URL (如果没有配置,使用默认节点)
|
||||
*/
|
||||
fun getWsUrl(): String {
|
||||
val nodeResult = getAvailableNode()
|
||||
return if (nodeResult.isSuccess) {
|
||||
nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL
|
||||
} else {
|
||||
logger.warn("没有可用的用户配置节点,使用默认 WS 节点")
|
||||
DEFAULT_WS_URL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
*/
|
||||
@Transactional
|
||||
fun addNode(request: AddRpcNodeRequest): Result<RpcNodeConfig> {
|
||||
return try {
|
||||
// 1. 验证请求
|
||||
val providerType = try {
|
||||
RpcProviderType.valueOf(request.providerType.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return Result.failure(IllegalArgumentException("不支持的服务商类型: ${request.providerType}"))
|
||||
}
|
||||
|
||||
// 2. 构建 HTTP 和 WS URL
|
||||
val (httpUrl, wsUrl) = if (providerType == RpcProviderType.CUSTOM) {
|
||||
// 自定义节点,使用用户提供的 URL
|
||||
if (request.httpUrl.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("自定义节点必须提供 HTTP URL"))
|
||||
}
|
||||
Pair(request.httpUrl, request.wsUrl)
|
||||
} else {
|
||||
// 主流服务商,使用模板生成 URL
|
||||
if (request.apiKey.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("${request.providerType} 节点必须提供 API Key"))
|
||||
}
|
||||
val httpTemplate = PROVIDER_HTTP_TEMPLATES[providerType]
|
||||
?: return Result.failure(IllegalArgumentException("未找到 ${request.providerType} 的 HTTP URL 模板"))
|
||||
val wsTemplate = PROVIDER_WS_TEMPLATES[providerType]
|
||||
Pair(
|
||||
httpTemplate.replace("{apiKey}", request.apiKey),
|
||||
wsTemplate?.replace("{apiKey}", request.apiKey)
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 校验节点可用性
|
||||
val validationResult = validateNode(httpUrl, wsUrl)
|
||||
if (validationResult.isFailure) {
|
||||
return Result.failure(validationResult.exceptionOrNull() ?: Exception("节点验证失败"))
|
||||
}
|
||||
|
||||
val checkResult = validationResult.getOrNull()!!
|
||||
|
||||
// 检查节点是否健康,如果不健康则不允许添加
|
||||
if (checkResult.status != NodeHealthStatus.HEALTHY) {
|
||||
return Result.failure(IllegalArgumentException("节点不可用: ${checkResult.message}"))
|
||||
}
|
||||
|
||||
// 4. 加密 API Key (如果有)
|
||||
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 5. 获取当前最大优先级
|
||||
val maxPriority = rpcNodeConfigRepository.findAllByOrderByPriorityAsc()
|
||||
.maxOfOrNull { it.priority } ?: 0
|
||||
|
||||
// 6. 创建节点配置
|
||||
val node = RpcNodeConfig(
|
||||
providerType = providerType.name,
|
||||
name = request.name,
|
||||
httpUrl = httpUrl,
|
||||
wsUrl = wsUrl,
|
||||
apiKey = encryptedApiKey,
|
||||
enabled = true,
|
||||
priority = maxPriority + 1, // 新节点放到最后
|
||||
lastCheckTime = checkResult.checkTime,
|
||||
lastCheckStatus = checkResult.status.name,
|
||||
responseTimeMs = checkResult.responseTimeMs
|
||||
)
|
||||
|
||||
val savedNode = rpcNodeConfigRepository.save(node)
|
||||
logger.info("成功添加 RPC 节点: ${savedNode.name} (${savedNode.httpUrl})")
|
||||
Result.success(savedNode)
|
||||
} catch (e: Exception) {
|
||||
logger.error("添加节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
* 默认节点不允许更新(作为兜底,不应该返回给前端)
|
||||
*/
|
||||
@Transactional
|
||||
fun updateNode(request: UpdateRpcNodeRequest): Result<RpcNodeConfig> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: ${request.id}"))
|
||||
|
||||
// 如果是默认节点,不允许更新
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
val updatedNode = node.copy(
|
||||
name = request.name ?: node.name,
|
||||
enabled = request.enabled ?: node.enabled,
|
||||
priority = request.priority ?: node.priority,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val savedNode = rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("成功更新 RPC 节点: ${savedNode.name}")
|
||||
Result.success(savedNode)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
* 默认节点不允许删除(作为兜底,不应该返回给前端)
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteNode(id: Long): Result<Unit> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: $id"))
|
||||
|
||||
// 如果是默认节点,不允许删除
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
|
||||
}
|
||||
|
||||
rpcNodeConfigRepository.delete(node)
|
||||
logger.info("成功删除 RPC 节点: ${node.name}")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点优先级
|
||||
* 默认节点不允许更新优先级(作为兜底,始终排在最后)
|
||||
*/
|
||||
@Transactional
|
||||
fun updatePriority(id: Long, priority: Int): Result<Unit> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: $id"))
|
||||
|
||||
// 如果是默认节点,不允许更新优先级
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许更新优先级"))
|
||||
}
|
||||
|
||||
val updatedNode = node.copy(
|
||||
priority = priority,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("成功更新节点优先级: ${node.name} -> $priority")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点优先级失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个节点健康状态
|
||||
* 默认节点不应该被检查(作为兜底,不应该返回给前端)
|
||||
*/
|
||||
@Transactional
|
||||
fun checkNodeHealth(nodeId: Long): Result<NodeCheckResult> {
|
||||
return try {
|
||||
val node = rpcNodeConfigRepository.findById(nodeId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("节点不存在: $nodeId"))
|
||||
|
||||
// 如果是默认节点,不允许检查
|
||||
if (isDefaultNode(node)) {
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许检查"))
|
||||
}
|
||||
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrThrow()
|
||||
|
||||
// 更新节点健康状态
|
||||
val updatedNode = node.copy(
|
||||
lastCheckTime = checkResult.checkTime,
|
||||
lastCheckStatus = checkResult.status.name,
|
||||
responseTimeMs = checkResult.responseTimeMs,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("检查节点健康状态: ${node.name} -> ${checkResult.status}")
|
||||
Result.success(checkResult)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查节点健康状态失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查所有节点健康状态(不包含默认节点和禁用的节点)
|
||||
* 默认节点作为兜底,不应该返回给前端
|
||||
* 禁用的节点不应该被检查
|
||||
*/
|
||||
@Transactional
|
||||
fun checkAllNodesHealth(): Result<Map<Long, NodeCheckResult>> {
|
||||
return try {
|
||||
// 只查询启用的节点,过滤掉默认节点和禁用的节点
|
||||
val allNodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc()
|
||||
// 过滤掉默认节点,只检查用户配置的启用节点
|
||||
val nodes = allNodes.filterNot { isDefaultNode(it) }
|
||||
val results = mutableMapOf<Long, NodeCheckResult>()
|
||||
|
||||
for (node in nodes) {
|
||||
try {
|
||||
val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull()
|
||||
if (checkResult != null) {
|
||||
results[node.id!!] = checkResult
|
||||
|
||||
// 更新节点状态
|
||||
val updatedNode = node.copy(
|
||||
lastCheckTime = checkResult.checkTime,
|
||||
lastCheckStatus = checkResult.status.name,
|
||||
responseTimeMs = checkResult.responseTimeMs,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
rpcNodeConfigRepository.save(updatedNode)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查节点 ${node.name} 失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(results)
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量检查节点健康状态失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验节点可用性
|
||||
* 调用 eth_blockNumber 验证节点是否可用
|
||||
*/
|
||||
private fun validateNode(httpUrl: String, wsUrl: String?): Result<NodeCheckResult> {
|
||||
return try {
|
||||
logger.debug("开始验证节点: $httpUrl")
|
||||
|
||||
// 创建临时 RPC API
|
||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
||||
|
||||
// 调用 eth_blockNumber
|
||||
val startTime = System.currentTimeMillis()
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_blockNumber",
|
||||
params = emptyList()
|
||||
)
|
||||
|
||||
val response = kotlinx.coroutines.runBlocking { rpcApi.call(rpcRequest) }
|
||||
val responseTime = (System.currentTimeMillis() - startTime).toInt()
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("节点验证失败: HTTP ${response.code()}")
|
||||
return Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "HTTP 请求失败: ${response.code()}",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime
|
||||
))
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
logger.warn("节点验证失败: RPC 错误 ${rpcResponse.error.message}")
|
||||
return Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "RPC 错误: ${rpcResponse.error.message}",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime
|
||||
))
|
||||
}
|
||||
|
||||
val blockNumber = rpcResponse.result?.asString
|
||||
if (blockNumber.isNullOrBlank()) {
|
||||
return Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "区块号为空",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime
|
||||
))
|
||||
}
|
||||
|
||||
logger.info("节点验证成功: $httpUrl, 区块号: $blockNumber, 响应时间: ${responseTime}ms")
|
||||
Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.HEALTHY,
|
||||
message = "节点可用, 当前区块: $blockNumber",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = responseTime,
|
||||
blockNumber = blockNumber
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证节点失败: ${e.message}", e)
|
||||
Result.success(NodeCheckResult(
|
||||
status = NodeHealthStatus.UNHEALTHY,
|
||||
message = "验证失败: ${e.message}",
|
||||
checkTime = System.currentTimeMillis(),
|
||||
responseTimeMs = null
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点请求
|
||||
*/
|
||||
data class AddRpcNodeRequest(
|
||||
val providerType: String, // ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC
|
||||
val name: String,
|
||||
val apiKey: String? = null, // 主流服务商需要
|
||||
val httpUrl: String? = null, // CUSTOM 需要
|
||||
val wsUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新节点请求
|
||||
*/
|
||||
data class UpdateRpcNodeRequest(
|
||||
val id: Long,
|
||||
val name: String? = null,
|
||||
val enabled: Boolean? = null,
|
||||
val priority: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 节点检查结果
|
||||
*/
|
||||
data class NodeCheckResult(
|
||||
val status: NodeHealthStatus,
|
||||
val message: String,
|
||||
val checkTime: Long,
|
||||
val responseTimeMs: Int?,
|
||||
val blockNumber: String? = null
|
||||
)
|
||||
+42
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.SystemConfigDto
|
||||
import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest
|
||||
@@ -25,6 +25,19 @@ class SystemConfigService(
|
||||
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
||||
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
||||
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
||||
|
||||
/**
|
||||
* 遮蔽敏感信息,仅显示前4位和后4位
|
||||
* 例如:abcd1234...wxyz5678
|
||||
*/
|
||||
fun maskSensitiveValue(value: String?): String? {
|
||||
if (value == null) return null
|
||||
return when {
|
||||
value.length <= 8 -> "****" // 太短则完全遮蔽
|
||||
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
|
||||
else -> "${value.take(4)}...${value.takeLast(4)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,10 +49,38 @@ class SystemConfigService(
|
||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||
val autoRedeem = isAutoRedeemEnabled()
|
||||
|
||||
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val builderSecretDisplay = builderSecret?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return SystemConfigDto(
|
||||
builderApiKeyConfigured = builderApiKey != null,
|
||||
builderSecretConfigured = builderSecret != null,
|
||||
builderPassphraseConfigured = builderPassphrase != null,
|
||||
builderApiKeyDisplay = builderApiKeyDisplay,
|
||||
builderSecretDisplay = builderSecretDisplay,
|
||||
builderPassphraseDisplay = builderPassphraseDisplay,
|
||||
autoRedeemEnabled = autoRedeem
|
||||
)
|
||||
}
|
||||
+133
-85
@@ -1,11 +1,13 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.dto.NotificationConfigData
|
||||
import com.wrbug.polymarketbot.dto.TelegramConfigData
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.DateUtils
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -63,9 +65,12 @@ class TelegramNotificationService(
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
price: String? = null, // 订单价格(可选,如果提供则直接使用)
|
||||
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
||||
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiKey: String? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null,
|
||||
@@ -82,35 +87,49 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 尝试从订单详情获取实际价格和数量
|
||||
var actualPrice: String? = null
|
||||
var actualSize: String? = null
|
||||
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
|
||||
var actualPrice: String? = price
|
||||
var actualSize: String? = size
|
||||
var actualSide: String = side
|
||||
var actualOutcome: String? = null // 市场方向(outcome)
|
||||
var actualOutcome: String? = outcome
|
||||
|
||||
if (orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
// 如果价格或数量未提供,尝试从订单详情获取
|
||||
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
try {
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
||||
val order = orderResponse.body()!!
|
||||
actualPrice = order.price
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
actualSide = order.side // 使用订单详情中的 side
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
if (orderResponse.isSuccessful) {
|
||||
val order = orderResponse.body()
|
||||
if (order != null) {
|
||||
if (actualPrice == null) {
|
||||
actualPrice = order.price
|
||||
}
|
||||
if (actualSize == null) {
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
}
|
||||
actualSide = order.side // 使用订单详情中的 side
|
||||
if (actualOutcome == null) {
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
}
|
||||
} else {
|
||||
logger.debug("查询订单详情失败: 响应体为空, orderId=$orderId")
|
||||
}
|
||||
} else {
|
||||
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
|
||||
logger.debug("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询订单详情失败,使用默认值: ${e.message}", e)
|
||||
logger.warn("查询订单详情失败: orderId=$orderId, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val price = actualPrice ?: "0"
|
||||
val size = actualSize ?: "0"
|
||||
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val finalPrice = actualPrice ?: "0"
|
||||
val finalSize = actualSize ?: "0"
|
||||
|
||||
// 计算订单金额 = price × 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)
|
||||
@@ -124,8 +143,8 @@ class TelegramNotificationService(
|
||||
marketSlug = marketSlug,
|
||||
side = actualSide,
|
||||
outcome = actualOutcome,
|
||||
price = price,
|
||||
size = size,
|
||||
price = finalPrice,
|
||||
size = finalSize,
|
||||
amount = amount,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
@@ -294,20 +313,10 @@ class TelegramNotificationService(
|
||||
else -> filterType
|
||||
}
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
@@ -357,13 +366,17 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
// 格式化价格和数量
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """🚫 <b>$orderFiltered</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
@@ -568,6 +581,68 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化价格显示(保留最多4位小数,截断不四舍五入)
|
||||
*/
|
||||
private fun formatPrice(price: String): String {
|
||||
return try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val formatted = if (priceDecimal.scale() > 4) {
|
||||
priceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
priceDecimal.stripTrailingZeros()
|
||||
}
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) {
|
||||
price
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数量显示(保留最多2位小数,截断不四舍五入)
|
||||
*/
|
||||
private fun formatQuantity(quantity: String): String {
|
||||
return try {
|
||||
val quantityDecimal = quantity.toSafeBigDecimal()
|
||||
val formatted = if (quantityDecimal.scale() > 2) {
|
||||
quantityDecimal.setScale(2, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
quantityDecimal.stripTrailingZeros()
|
||||
}
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) {
|
||||
quantity
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建账户信息显示(格式:账户名(钱包地址))
|
||||
*/
|
||||
private fun buildAccountInfo(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
unknownAccount: String
|
||||
): String {
|
||||
return when {
|
||||
!accountName.isNullOrBlank() && !walletAddress.isNullOrBlank() -> {
|
||||
// 有账户名和钱包地址:账户名(钱包地址)
|
||||
"${accountName}(${maskAddress(walletAddress)})"
|
||||
}
|
||||
!accountName.isNullOrBlank() -> {
|
||||
// 只有账户名
|
||||
accountName
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
// 只有钱包地址
|
||||
maskAddress(walletAddress)
|
||||
}
|
||||
else -> {
|
||||
// 都没有
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单成功消息
|
||||
*/
|
||||
@@ -611,18 +686,8 @@ class TelegramNotificationService(
|
||||
else -> side
|
||||
}
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
// 构建跟单信息(如果有)
|
||||
val copyTradingInfo = mutableListOf<String>()
|
||||
@@ -638,7 +703,7 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
@@ -692,14 +757,18 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
// 格式化价格和数量
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """✅ <b>$orderCreatedSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
|
||||
@@ -746,20 +815,10 @@ class TelegramNotificationService(
|
||||
else -> side
|
||||
}
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 错误信息已经是后端返回的 msg,不需要截断(但为了安全,限制长度)
|
||||
val shortErrorMessage = if (errorMessage.length > 500) {
|
||||
@@ -816,13 +875,17 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
// 格式化价格和数量
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """❌ <b>$orderCreatedFailed</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
@@ -887,20 +950,10 @@ class TelegramNotificationService(
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
|
||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
||||
val accountInfo: String = when {
|
||||
!accountName.isNullOrBlank() -> {
|
||||
accountName!!
|
||||
}
|
||||
!walletAddress.isNullOrBlank() -> {
|
||||
maskAddress(walletAddress!!)
|
||||
}
|
||||
else -> {
|
||||
unknownAccount
|
||||
}
|
||||
}
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
@@ -921,12 +974,7 @@ class TelegramNotificationService(
|
||||
|
||||
// 构建仓位列表
|
||||
val positionsText = positions.joinToString("\n") { position ->
|
||||
val quantityDisplay = try {
|
||||
val quantityDecimal = position.quantity.toSafeBigDecimal()
|
||||
quantityDecimal.stripTrailingZeros().toPlainString()
|
||||
} catch (e: Exception) {
|
||||
position.quantity
|
||||
}
|
||||
val quantityDisplay = formatQuantity(position.quantity)
|
||||
val valueDisplay = try {
|
||||
val valueDecimal = position.value.toSafeBigDecimal()
|
||||
val formatted = if (valueDecimal.scale() > 4) {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.UserCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.UserDto
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
@@ -15,6 +17,13 @@ object DateUtils {
|
||||
*/
|
||||
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
/**
|
||||
* 使用系统时区的日期时间格式化器(用于显示)
|
||||
* 格式:yyyy-MM-dd HH:mm:ss
|
||||
*/
|
||||
private val displayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
/**
|
||||
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
|
||||
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
|
||||
@@ -61,5 +70,19 @@ object DateUtils {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳(毫秒)格式化为可读的日期时间字符串(使用系统时区)
|
||||
* @param timestamp 时间戳(毫秒),如果为 null 则使用当前时间
|
||||
* @return 格式化的日期时间字符串,格式:yyyy-MM-dd HH:mm:ss
|
||||
*/
|
||||
fun formatDateTime(timestamp: Long? = null): String {
|
||||
val instant = if (timestamp != null) {
|
||||
Instant.ofEpochMilli(timestamp)
|
||||
} else {
|
||||
Instant.now()
|
||||
}
|
||||
return displayFormatter.format(instant)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ object EthereumUtils {
|
||||
* @return 函数选择器,例如 "0x12345678"
|
||||
*/
|
||||
fun getFunctionSelector(functionSignature: String): String {
|
||||
val hash = keccak256(functionSignature.toByteArray())
|
||||
val hash = keccak256Hex(functionSignature.toByteArray())
|
||||
return "0x" + hash.substring(0, 8)
|
||||
}
|
||||
|
||||
@@ -77,16 +77,109 @@ object EthereumUtils {
|
||||
return BigInteger(cleanHex, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ABI 编码的响应中解析 uint256 数组
|
||||
* ABI 编码格式:
|
||||
* - offset (32 bytes): 数组数据的位置偏移量
|
||||
* - length (32 bytes): 数组长度
|
||||
* - data: 每个元素 32 字节
|
||||
* @param hexResult 十六进制结果(完整的 ABI 编码响应)
|
||||
* @param offset 数组数据的偏移位置(字节数,从 offset 位置开始读取)
|
||||
* @return BigInteger 数组
|
||||
*/
|
||||
fun decodeUint256Array(hexResult: String, offset: Int = 0): List<BigInteger> {
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
if (cleanHex.length < (offset + 1) * 64) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// 从 offset 位置开始读取
|
||||
val startPos = offset * 64 // 每个 uint256 是 64 个十六进制字符
|
||||
val lengthHex = cleanHex.substring(startPos, startPos + 64)
|
||||
val length = BigInteger(lengthHex, 16).toInt()
|
||||
|
||||
if (length <= 0 || length > 100) { // 防止异常数据
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val result = mutableListOf<BigInteger>()
|
||||
for (i in 0 until length) {
|
||||
val elementStart = startPos + 64 + (i * 64) // 跳过长度字段
|
||||
if (elementStart + 64 > cleanHex.length) {
|
||||
break
|
||||
}
|
||||
val elementHex = cleanHex.substring(elementStart, elementStart + 64)
|
||||
result.add(BigInteger(elementHex, 16))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ABI 编码的元组响应中解析数据
|
||||
* 用于解析 getCondition 返回的 (uint256 payoutDenominator, uint256[] payouts)
|
||||
* @param hexResult 十六进制结果
|
||||
* @return Pair<payoutDenominator, payouts>
|
||||
*/
|
||||
fun decodeConditionResult(hexResult: String): Pair<BigInteger, List<BigInteger>> {
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
|
||||
// 第一个 32 字节:payoutDenominator
|
||||
val payoutDenominatorHex = cleanHex.substring(0, 64)
|
||||
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
|
||||
|
||||
// 第二个 32 字节:payouts 数组的偏移量(通常是 0x40 = 64 字节)
|
||||
val offsetHex = cleanHex.substring(64, 128)
|
||||
val offset = BigInteger(offsetHex, 16).toInt() / 32 // 转换为 32 字节单位
|
||||
|
||||
// 从 offset 位置解析数组
|
||||
val payouts = decodeUint256Array(hexResult, offset)
|
||||
|
||||
return Pair(payoutDenominator, payouts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将十六进制字符串转换为字节数组
|
||||
* @param hex 十六进制字符串(带或不带 0x 前缀)
|
||||
* @return 字节数组
|
||||
*/
|
||||
fun hexToBytes(hex: String): ByteArray {
|
||||
val cleanHex = hex.removePrefix("0x")
|
||||
return ByteArray(cleanHex.length / 2) { i ->
|
||||
cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节数组转换为十六进制字符串
|
||||
* @param bytes 字节数组
|
||||
* @return 十六进制字符串(带 0x 前缀)
|
||||
*/
|
||||
fun bytesToHex(bytes: ByteArray): String {
|
||||
return "0x" + bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Keccak-256 哈希(Ethereum 标准)
|
||||
* 使用 BouncyCastle 库实现真正的 Keccak-256
|
||||
* @param data 输入数据
|
||||
* @return 32 字节的哈希值
|
||||
*/
|
||||
private fun keccak256(data: ByteArray): String {
|
||||
fun keccak256(data: ByteArray): ByteArray {
|
||||
val digest = KeccakDigest(256)
|
||||
digest.update(data, 0, data.size)
|
||||
val hash = ByteArray(digest.digestSize)
|
||||
digest.doFinal(hash, 0)
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Keccak-256 哈希并返回十六进制字符串
|
||||
* @param data 输入数据
|
||||
* @return 十六进制哈希字符串
|
||||
*/
|
||||
fun keccak256Hex(data: ByteArray): String {
|
||||
return keccak256(data).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@ package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* JSON 工具类
|
||||
* 用于解析 JSON 字符串
|
||||
*/
|
||||
object JsonUtils {
|
||||
|
||||
private val gson = Gson()
|
||||
@Component
|
||||
class JsonUtils(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串数组
|
||||
|
||||
@@ -26,21 +26,23 @@ fun BigDecimal.multi(value: Any): BigDecimal {
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* BigDecimal除法扩展函数
|
||||
* BigDecimal除法扩展函数(带精度和舍入模式)
|
||||
* 安全地将BigDecimal与任意数值类型相除
|
||||
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
|
||||
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
|
||||
* @param scale 精度(小数位数)
|
||||
* @param roundingMode 舍入模式
|
||||
* @return 除法结果,如果转换失败返回IllegalBigDecimal
|
||||
*/
|
||||
fun BigDecimal.div(value: Any): BigDecimal {
|
||||
fun BigDecimal.div(value: Any, scale: Int = 18, roundingMode: RoundingMode = RoundingMode.HALF_UP): BigDecimal {
|
||||
kotlin.runCatching {
|
||||
if (value is BigDecimal) {
|
||||
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
val divisor = when (value) {
|
||||
is BigDecimal -> value
|
||||
is BigInteger -> value.toBigDecimal()
|
||||
else -> BigDecimal(value.toString())
|
||||
}
|
||||
if (value is BigInteger) {
|
||||
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
return divide(divisor, scale, roundingMode)
|
||||
}
|
||||
return IllegalBigDecimal
|
||||
}
|
||||
|
||||
@@ -1,37 +1,138 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import jakarta.annotation.PreDestroy
|
||||
|
||||
/**
|
||||
* Retrofit 客户端工厂
|
||||
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
|
||||
*
|
||||
* 注意:为了避免内存泄漏,本类会缓存和复用客户端实例
|
||||
*/
|
||||
@Component
|
||||
class RetrofitFactory(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String
|
||||
private val gammaBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
|
||||
|
||||
// 共享的 OkHttpClient(用于不需要认证的 API)
|
||||
private val sharedOkHttpClient: OkHttpClient by lazy {
|
||||
createClient().build()
|
||||
}
|
||||
|
||||
// 共享的 OkHttpClient(用于需要跟随重定向的 API)
|
||||
private val sharedOkHttpClientWithRedirect: OkHttpClient by lazy {
|
||||
createClient()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
// 缓存 Gamma API 客户端(单例)
|
||||
private val gammaApi: PolymarketGammaApi by lazy {
|
||||
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
|
||||
gammaBaseUrl.dropLast(1)
|
||||
} else {
|
||||
gammaBaseUrl
|
||||
}
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketGammaApi::class.java)
|
||||
}
|
||||
|
||||
// 缓存 Data API 客户端(单例)
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = "https://data-api.polymarket.com"
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(sharedOkHttpClientWithRedirect)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketDataApi::class.java)
|
||||
}
|
||||
|
||||
// 缓存 GitHub API 客户端(单例)
|
||||
private val githubApi: GitHubApi by lazy {
|
||||
val baseUrl = "https://api.github.com"
|
||||
|
||||
// 添加拦截器,设置 Accept 头以获取 reactions 数据
|
||||
val githubInterceptor = object : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(githubInterceptor)
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(GitHubApi::class.java)
|
||||
}
|
||||
|
||||
// 缓存不带认证的 CLOB API 客户端(单例)
|
||||
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
|
||||
// 缓存带认证的 CLOB API 客户端:walletAddress -> PolymarketClobApi
|
||||
// 注意:每个账户使用不同的 API Key,需要不同的客户端
|
||||
private val clobApiCache = ConcurrentHashMap<String, PolymarketClobApi>()
|
||||
|
||||
// 缓存 RPC API 客户端:rpcUrl -> EthereumRpcApi
|
||||
private val rpcApiCache = ConcurrentHashMap<String, EthereumRpcApi>()
|
||||
|
||||
// 缓存 Builder Relayer API 客户端:relayerUrl -> BuilderRelayerApi
|
||||
private val builderRelayerApiCache = ConcurrentHashMap<String, BuilderRelayerApi>()
|
||||
|
||||
/**
|
||||
* 创建带认证的 Polymarket CLOB API 客户端
|
||||
* 按钱包地址缓存,避免重复创建
|
||||
* @param apiKey API Key
|
||||
* @param apiSecret API Secret
|
||||
* @param apiPassphrase API Passphrase
|
||||
@@ -44,129 +145,169 @@ class RetrofitFactory(
|
||||
apiPassphrase: String,
|
||||
walletAddress: String
|
||||
): PolymarketClobApi {
|
||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
|
||||
|
||||
// 添加响应日志拦截器,用于调试 JSON 解析错误
|
||||
val responseLoggingInterceptor = ResponseLoggingInterceptor()
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(responseLoggingInterceptor)
|
||||
.build()
|
||||
|
||||
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
|
||||
val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
// 使用钱包地址作为缓存键(每个账户使用不同的 API Key)
|
||||
return clobApiCache.computeIfAbsent(walletAddress) {
|
||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
|
||||
|
||||
// 添加响应日志拦截器,用于调试 JSON 解析错误
|
||||
val responseLoggingInterceptor = ResponseLoggingInterceptor()
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(responseLoggingInterceptor)
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不带认证的 Polymarket CLOB API 客户端
|
||||
* 用于不需要认证的查询接口
|
||||
* @return PolymarketClobApi 客户端
|
||||
* @return PolymarketClobApi 客户端(单例)
|
||||
*/
|
||||
fun createClobApiWithoutAuth(): PolymarketClobApi {
|
||||
// 添加响应日志拦截器,用于调试 JSON 解析错误
|
||||
val responseLoggingInterceptor = ResponseLoggingInterceptor()
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(responseLoggingInterceptor)
|
||||
.build()
|
||||
|
||||
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
|
||||
val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
return clobApiWithoutAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Ethereum RPC API 客户端
|
||||
* 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL
|
||||
* 如果 RPC 不可用,将抛出异常
|
||||
* 按 RPC URL 缓存,避免重复创建
|
||||
* @param rpcUrl RPC 节点 URL
|
||||
* @return EthereumRpcApi 客户端
|
||||
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
|
||||
*/
|
||||
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
|
||||
val okHttpClient = createClient().build()
|
||||
// 确保实际的 RPC URL 以 / 结尾
|
||||
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
|
||||
rpcUrl
|
||||
} else {
|
||||
"$rpcUrl/"
|
||||
}
|
||||
|
||||
// 创建 lenient 模式的 Gson
|
||||
val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(rpcUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(EthereumRpcApi::class.java)
|
||||
// 使用 RPC URL 作为缓存键
|
||||
return rpcApiCache.computeIfAbsent(actualRpcUrl) {
|
||||
// 验证 RPC 是否可用(仅在新创建时验证)
|
||||
validateRpcAvailability(actualRpcUrl)
|
||||
|
||||
// 使用固定的 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
|
||||
@@ -185,57 +326,94 @@ class RetrofitFactory(
|
||||
relayerUrl
|
||||
}
|
||||
|
||||
// 添加 Builder 认证拦截器
|
||||
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(builderAuthInterceptor)
|
||||
.build()
|
||||
|
||||
val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(BuilderRelayerApi::class.java)
|
||||
// 使用 baseUrl 作为缓存键(注意:如果 API Key 变化,需要清理缓存)
|
||||
return builderRelayerApiCache.computeIfAbsent(baseUrl) {
|
||||
// 添加 Builder 认证拦截器
|
||||
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(builderAuthInterceptor)
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(BuilderRelayerApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 GitHub API 客户端
|
||||
* GitHub API 是公开 API,不需要认证(但建议使用 token 提高速率限制)
|
||||
* 添加 Accept 头以获取 reactions 数据
|
||||
* @return GitHubApi 客户端
|
||||
* @return GitHubApi 客户端(单例)
|
||||
*/
|
||||
fun createGitHubApi(): GitHubApi {
|
||||
val baseUrl = "https://api.github.com"
|
||||
return githubApi
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存(用于测试或配置变更时)
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
logger.info("清理 RetrofitFactory 缓存")
|
||||
clobApiCache.clear()
|
||||
rpcApiCache.clear()
|
||||
builderRelayerApiCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定钱包地址的 CLOB API 缓存
|
||||
* 用于 API Key 变更时
|
||||
*/
|
||||
fun clearClobApiCache(walletAddress: String) {
|
||||
clobApiCache.remove(walletAddress)
|
||||
logger.debug("已清理 CLOB API 缓存: $walletAddress")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定 RPC URL 的 RPC API 缓存
|
||||
* 用于 RPC 节点变更时
|
||||
*/
|
||||
fun clearRpcApiCache(rpcUrl: String) {
|
||||
val actualRpcUrl = if (rpcUrl.endsWith("/")) rpcUrl else "$rpcUrl/"
|
||||
rpcApiCache.remove(actualRpcUrl)
|
||||
logger.debug("已清理 RPC API 缓存: $actualRpcUrl")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC URL 替换拦截器
|
||||
* 用于将固定的 baseUrl 替换为实际的 RPC URL
|
||||
*/
|
||||
class RpcUrlReplaceInterceptor(
|
||||
private val fixedBaseUrl: String,
|
||||
private val actualRpcUrl: String
|
||||
) : Interceptor {
|
||||
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val originalUrl = originalRequest.url
|
||||
|
||||
// 添加拦截器,设置 Accept 头以获取 reactions 数据
|
||||
val githubInterceptor = object : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
// 将固定 baseUrl 替换为实际的 RPC URL
|
||||
val originalUrlString = originalUrl.toString()
|
||||
val newUrlString = originalUrlString.replace(fixedBaseUrl, actualRpcUrl)
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(githubInterceptor)
|
||||
// 使用 HttpUrl 解析新 URL,确保格式正确
|
||||
val newUrl = newUrlString.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
|
||||
|
||||
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(GitHubApi::class.java)
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,22 +437,35 @@ class ResponseLoggingInterceptor : Interceptor {
|
||||
val responseBody = response.peekBody(2048)
|
||||
val responseBodyString = responseBody.string()
|
||||
|
||||
// 检查是否是有效的 JSON
|
||||
val isJson = responseBodyString.trim().startsWith("{") ||
|
||||
responseBodyString.trim().startsWith("[")
|
||||
// 检查响应体是否为空
|
||||
val isEmpty = responseBodyString.isBlank()
|
||||
|
||||
if (!isJson || !response.isSuccessful) {
|
||||
// 检查是否是有效的 JSON
|
||||
val trimmedBody = responseBodyString.trim()
|
||||
val isJson = !isEmpty && (
|
||||
trimmedBody.startsWith("{") ||
|
||||
trimmedBody.startsWith("[")
|
||||
)
|
||||
|
||||
// 如果响应体为空或不是 JSON,记录警告
|
||||
if (isEmpty || !isJson) {
|
||||
val bodyPreview = if (isEmpty) {
|
||||
"(空响应体)"
|
||||
} else {
|
||||
trimmedBody.take(500)
|
||||
}
|
||||
logger.warn(
|
||||
"API 响应异常: method=${request.method}, url=${request.url}, " +
|
||||
"code=${response.code}, isJson=$isJson, " +
|
||||
"responseBody=${responseBodyString.take(500)}"
|
||||
"code=${response.code}, isJson=$isJson, isEmpty=$isEmpty, " +
|
||||
"responseBody=$bodyPreview"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果读取响应体失败,记录异常但不影响响应
|
||||
logger.debug("读取响应体失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package com.wrbug.polymarketbot.websocket
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||
import com.wrbug.polymarketbot.service.WebSocketSubscriptionService
|
||||
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@@ -40,23 +40,12 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
|
||||
# Polygon RPC 配置(用于查询链上余额)
|
||||
# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
|
||||
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# Builder Relayer 配置(用于 Gasless 交易)
|
||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
||||
|
||||
# 仓位推送配置
|
||||
# 轮询间隔(毫秒),默认3秒
|
||||
position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000}
|
||||
# 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接
|
||||
position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000}
|
||||
|
||||
# 跟单轮询配置
|
||||
# 轮询间隔(毫秒),默认2秒
|
||||
copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ============================================
|
||||
-- V11: 添加最大仓位配置字段
|
||||
-- 在 copy_trading 表中添加最大仓位金额和最大仓位数量配置
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE copy_trading
|
||||
ADD COLUMN max_position_value DECIMAL(20, 8) NULL COMMENT '最大仓位金额(USDC),NULL表示不启用' AFTER max_price,
|
||||
ADD COLUMN max_position_count INT NULL COMMENT '最大仓位数量,NULL表示不启用' AFTER max_position_value;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 创建 RPC 节点配置表
|
||||
CREATE TABLE rpc_node_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
|
||||
provider_type VARCHAR(50) NOT NULL COMMENT '服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC',
|
||||
name VARCHAR(100) NOT NULL COMMENT '节点名称',
|
||||
http_url VARCHAR(500) NOT NULL COMMENT 'HTTP RPC URL',
|
||||
ws_url VARCHAR(500) COMMENT 'WebSocket URL (可选)',
|
||||
api_key VARCHAR(200) COMMENT 'API Key (加密存储)',
|
||||
enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
priority INT DEFAULT 0 COMMENT '优先级(数字越小优先级越高)',
|
||||
last_check_time BIGINT COMMENT '最后检查时间(毫秒时间戳)',
|
||||
last_check_status VARCHAR(20) COMMENT '最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN',
|
||||
response_time_ms INT COMMENT '最后一次响应时间(毫秒)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_enabled_priority (enabled, priority),
|
||||
INDEX idx_last_check_status (last_check_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Polygon RPC 节点配置表';
|
||||
|
||||
-- 插入默认公共节点 (PublicNode)
|
||||
INSERT INTO rpc_node_config (
|
||||
provider_type,
|
||||
name,
|
||||
http_url,
|
||||
ws_url,
|
||||
enabled,
|
||||
priority,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_check_status
|
||||
) VALUES (
|
||||
'PUBLIC',
|
||||
'PublicNode (Default)',
|
||||
'https://polygon.publicnode.com',
|
||||
'wss://polygon.publicnode.com',
|
||||
TRUE,
|
||||
999,
|
||||
UNIX_TIMESTAMP() * 1000,
|
||||
UNIX_TIMESTAMP() * 1000,
|
||||
'UNKNOWN'
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user