Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e48149a19b | |||
| c0e0942404 | |||
| 6b8c11f171 | |||
| 3e667b70fd | |||
| 2bb8cbc564 | |||
| 8b73121c5d | |||
| 95930332df | |||
| 185cade11d | |||
| d9443e0d56 | |||
| a097376db7 | |||
| 6e5ccdfe5c | |||
| 21da06d8b0 | |||
| 5a83444b56 | |||
| 591e678f73 | |||
| 1cfdcb88d3 | |||
| 2cc9cf82ba | |||
| 00f0898d98 | |||
| 1de9b5e958 | |||
| c8e422a94b | |||
| 60e9f9235d | |||
| 46baa416f4 | |||
| ecdb8af14a | |||
| d6027e48eb | |||
| eb45013a93 | |||
| f97bd5b9d9 | |||
| 502b4fb0b7 | |||
| ad2fa4eef2 | |||
| b5908a9bbf |
@@ -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
|
||||
|
||||
+137
-3
@@ -1,3 +1,133 @@
|
||||
# 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
|
||||
|
||||
## 🚀 主要功能
|
||||
@@ -46,6 +176,10 @@
|
||||
- `failed_trade` 表的删除通过 V16 migration 处理
|
||||
- 确保已有数据库的 migration checksum 保持一致
|
||||
|
||||
### 修复前端编译错误
|
||||
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
|
||||
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`)
|
||||
@@ -72,9 +206,9 @@
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 28 个文件
|
||||
- **代码变更**: +1595 行 / -678 行
|
||||
- **主要提交**: 7 个提交
|
||||
- **文件变更**: 29 个文件
|
||||
- **代码变更**: +1597 行 / -678 行
|
||||
- **主要提交**: 8 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +3,7 @@ package com.wrbug.polymarketbot.controller.auth
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.*
|
||||
@RequestMapping("/api/auth")
|
||||
class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val messageSource: MessageSource
|
||||
private val messageSource: MessageSource,
|
||||
private val webSocketTicketService: WebSocketTicketService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
||||
@@ -25,7 +27,10 @@ class AuthController(
|
||||
* 登录接口
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
fun login(@RequestBody request: LoginRequest): ResponseEntity<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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ data class AccountImportRequest(
|
||||
val privateKey: String, // 私钥(前端加密后传输)
|
||||
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
||||
val accountName: String? = null,
|
||||
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
|
||||
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
|
||||
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ data class AccountDto(
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址
|
||||
val accountName: String?,
|
||||
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
|
||||
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
|
||||
val apiSecretConfigured: Boolean, // API Secret 是否已配置
|
||||
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
||||
|
||||
@@ -14,3 +14,10 @@ data class CheckFirstUseResponse(
|
||||
val isFirstUse: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* WebSocket 票据响应
|
||||
*/
|
||||
data class WebSocketTicketResponse(
|
||||
val ticket: String
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+23
@@ -61,5 +61,28 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
*/
|
||||
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
|
||||
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询指定时间之前创建且状态不为指定状态的订单
|
||||
*/
|
||||
fun findByCreatedAtBeforeAndStatusNot(beforeTime: Long, status: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询指定跟单配置下的活跃仓位数量
|
||||
* 活跃仓位定义为 remainingQuantity > 0 的不同 (marketId, outcomeIndex) 组合
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT CONCAT(t.marketId, '_', COALESCE(t.outcomeIndex, -1))) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.remainingQuantity > 0")
|
||||
fun countActivePositions(copyTradingId: Long): Int
|
||||
|
||||
/**
|
||||
* 检查指定市场是否存在活跃仓位
|
||||
*/
|
||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
||||
|
||||
/**
|
||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
||||
*/
|
||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
||||
}
|
||||
|
||||
|
||||
@@ -99,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) {
|
||||
@@ -150,6 +151,7 @@ class AccountService(
|
||||
accountName = accountName,
|
||||
isDefault = false, // 不再支持默认账户
|
||||
isEnabled = request.isEnabled,
|
||||
walletType = request.walletType, // 保存钱包类型
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
@@ -226,11 +228,12 @@ class AccountService(
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
* 列表接口只返回基本信息,不查询统计信息(统计信息只在详情接口中查询)
|
||||
*/
|
||||
fun getAccountList(): Result<AccountListResponse> {
|
||||
return try {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
val accountDtos = accounts.map { toDto(it) }
|
||||
val accountDtos = accounts.map { toBasicDto(it) }
|
||||
|
||||
Result.success(
|
||||
AccountListResponse(
|
||||
@@ -351,7 +354,30 @@ class AccountService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
* 转换为基础 DTO(列表使用,不包含统计信息)
|
||||
* 列表接口只返回基本信息,不查询统计信息,以提高性能
|
||||
*/
|
||||
private fun toBasicDto(account: Account): AccountDto {
|
||||
return AccountDto(
|
||||
id = account.id!!,
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress,
|
||||
accountName = account.accountName,
|
||||
isEnabled = account.isEnabled,
|
||||
walletType = account.walletType,
|
||||
apiKeyConfigured = account.apiKey != null,
|
||||
apiSecretConfigured = account.apiSecret != null,
|
||||
apiPassphraseConfigured = account.apiPassphrase != null,
|
||||
totalOrders = null,
|
||||
totalPnl = null,
|
||||
activeOrders = null,
|
||||
completedOrders = null,
|
||||
positionCount = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为完整 DTO(详情使用,包含交易统计数据)
|
||||
* 包含交易统计数据(总订单数、总盈亏、活跃订单数、已完成订单数、持仓数量)
|
||||
*/
|
||||
private fun toDto(account: Account): AccountDto {
|
||||
@@ -363,6 +389,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,
|
||||
|
||||
+12
@@ -382,9 +382,15 @@ class PositionCheckService(
|
||||
|
||||
if (ordersToMarkAsSold.isNotEmpty()) {
|
||||
// 有订单创建时间超过2分钟,认为仓位已被出售
|
||||
try {
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
|
||||
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("无法获取市场价格,跳过标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
|
||||
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
|
||||
@@ -413,9 +419,15 @@ class PositionCheckService(
|
||||
}
|
||||
|
||||
// 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
try {
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
|
||||
copyTrading.id, marketId, outcomeIndex)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,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()
|
||||
}
|
||||
}
|
||||
+201
-35
@@ -39,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"
|
||||
@@ -78,60 +85,162 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 获取 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 {
|
||||
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}"))
|
||||
}
|
||||
|
||||
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexResult = rpcResponse.result?.asString
|
||||
?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
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 余额
|
||||
@@ -606,7 +715,7 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 从链上查询市场条件(Condition)的结算结果
|
||||
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
|
||||
* 通过调用 ConditionalTokens 合约的 conditions mapping 和 payoutNumerators mapping
|
||||
*
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @return Result<Pair<payoutDenominator, payouts>>
|
||||
@@ -625,44 +734,101 @@ class BlockchainService(
|
||||
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 构建 getCondition(bytes32) 函数调用
|
||||
// 函数签名: getCondition(bytes32)
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
|
||||
// 1. 调用 conditions(bytes32) 获取 outcomeSlotCount 和 payoutDenominator
|
||||
// 注意:这是一个公开的 mapping,Solidity 自动生成的 getter
|
||||
// 函数签名: conditions(bytes32) returns (uint outcomeSlotCount, uint payoutDenominator)
|
||||
val conditionsFunctionSelector = EthereumUtils.getFunctionSelector("conditions(bytes32)")
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
val data = functionSelector + encodedConditionId
|
||||
val conditionsData = conditionsFunctionSelector + encodedConditionId
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
val conditionsRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to data
|
||||
"data" to conditionsData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
val conditionsResponse = rpcApi.call(conditionsRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
if (!conditionsResponse.isSuccessful || conditionsResponse.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败 (conditions): ${conditionsResponse.code()} ${conditionsResponse.message()}"))
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
val conditionsRpcResponse = conditionsResponse.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
if (conditionsRpcResponse.error != null) {
|
||||
// 记录完整的错误信息,包括 code 和 data
|
||||
val errorMsg = "RPC 错误 (code=${conditionsRpcResponse.error.code}): ${conditionsRpcResponse.error.message}, data=${conditionsRpcResponse.error.data}"
|
||||
logger.error("查询市场条件(conditions)出现RPC错误: conditionId=$conditionId, $errorMsg")
|
||||
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$conditionsData")
|
||||
return Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexResult = rpcResponse.result?.asString
|
||||
val hexResult = conditionsRpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||
|
||||
// 解析 ABI 编码的返回结果
|
||||
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
|
||||
// 解析返回的 (outcomeSlotCount, payoutDenominator)
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
val outcomeSlotCountHex = cleanHex.substring(0, 64)
|
||||
val payoutDenominatorHex = cleanHex.substring(64, 128)
|
||||
|
||||
val outcomeSlotCount = BigInteger(outcomeSlotCountHex, 16).toInt()
|
||||
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
|
||||
|
||||
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
|
||||
if (outcomeSlotCount <= 0) {
|
||||
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
|
||||
return Result.success(Pair(BigInteger.ZERO, emptyList()))
|
||||
}
|
||||
|
||||
// 如果 payoutDenominator 为 0,说明市场尚未结算
|
||||
if (payoutDenominator == BigInteger.ZERO) {
|
||||
logger.debug("市场尚未结算: conditionId=$conditionId, payoutDenominator=$payoutDenominator")
|
||||
return Result.success(Pair(BigInteger.ZERO, emptyList()))
|
||||
}
|
||||
|
||||
// 2. 查询每个 outcome 的 payoutNumerators
|
||||
val payouts = mutableListOf<BigInteger>()
|
||||
for (i in 0 until outcomeSlotCount) {
|
||||
val payoutNumeratorsFunctionSelector = EthereumUtils.getFunctionSelector("payoutNumerators(bytes32,uint256)")
|
||||
val encodedIndex = EthereumUtils.encodeUint256(BigInteger.valueOf(i.toLong()))
|
||||
val payoutNumeratorsData = payoutNumeratorsFunctionSelector + encodedConditionId + encodedIndex
|
||||
|
||||
val payoutRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to payoutNumeratorsData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
val payoutResponse = rpcApi.call(payoutRequest)
|
||||
if (!payoutResponse.isSuccessful || payoutResponse.body() == null) {
|
||||
logger.warn("查询 payoutNumerators 失败: index=$i")
|
||||
continue
|
||||
}
|
||||
|
||||
val payoutRpcResponse = payoutResponse.body()!!
|
||||
if (payoutRpcResponse.error != null) {
|
||||
logger.warn("查询 payoutNumerators 错误: index=$i, error=${payoutRpcResponse.error.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val payoutHex = payoutRpcResponse.result?.asString ?: "0x0"
|
||||
val payout = EthereumUtils.decodeUint256(payoutHex)
|
||||
payouts.add(payout)
|
||||
}
|
||||
|
||||
Result.success(Pair(payoutDenominator, payouts))
|
||||
} catch (e: Exception) {
|
||||
|
||||
+66
-3
@@ -31,7 +31,8 @@ class MarketPriceService(
|
||||
* 获取当前市场最新价
|
||||
* 优先级:
|
||||
* 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0)
|
||||
* 2. CLOB API 查询订单簿价格(最准确,使用 bestBid)
|
||||
* 2. CLOB API 查询订单簿价格(最准确,优先使用,使用 bestBid)
|
||||
* 3. Gamma Market API 查询市场价格(快速,作为备选)
|
||||
*
|
||||
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
|
||||
*
|
||||
@@ -48,15 +49,22 @@ class MarketPriceService(
|
||||
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 2. 从 CLOB API 查询订单簿价格(最准确)
|
||||
// 2. 从 CLOB API 查询订单簿价格(最准确,优先使用)
|
||||
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
|
||||
if (orderbookPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 3. 从 Gamma Market API 查询市场价格(作为备选)
|
||||
val marketPrice = getPriceFromGammaMarket(marketId, outcomeIndex)
|
||||
if (marketPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return marketPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 如果所有数据源都失败,抛出异常
|
||||
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)"
|
||||
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询、订单簿查询和 Market API 均失败)"
|
||||
logger.error(errorMsg)
|
||||
throw IllegalStateException(errorMsg)
|
||||
}
|
||||
@@ -106,6 +114,61 @@ class MarketPriceService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Gamma Market API 获取价格
|
||||
* 使用 outcomePrices 字段,格式通常为 JSON 字符串 "[\"0.5\", \"0.5\"]"
|
||||
* 如果查询失败或 outcomePrices 为空,返回 null
|
||||
*/
|
||||
private suspend fun getPriceFromGammaMarket(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
|
||||
|
||||
if (!marketResponse.isSuccessful || marketResponse.body() == null) {
|
||||
logger.debug("Gamma Market API 查询失败: marketId=$marketId, code=${marketResponse.code()}")
|
||||
return null
|
||||
}
|
||||
|
||||
val markets = marketResponse.body()!!
|
||||
if (markets.isEmpty()) {
|
||||
logger.debug("Gamma Market API 未找到市场: marketId=$marketId")
|
||||
return null
|
||||
}
|
||||
|
||||
val market = markets.first()
|
||||
|
||||
// 尝试从 outcomePrices 字段获取价格
|
||||
val outcomePricesStr = market.outcomePrices
|
||||
if (outcomePricesStr.isNullOrBlank()) {
|
||||
logger.debug("Market outcomePrices 为空: marketId=$marketId")
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析 outcomePrices(通常是 JSON 数组字符串)
|
||||
val outcomePrices = try {
|
||||
// 移除首尾的方括号和引号,按逗号分割
|
||||
val cleanStr = outcomePricesStr.trim().removeSurrounding("[", "]")
|
||||
cleanStr.split(",").map {
|
||||
it.trim().removeSurrounding("\"").toSafeBigDecimal()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解析 outcomePrices 失败: marketId=$marketId, outcomePrices=$outcomePricesStr, error=${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (outcomePrices != null && outcomeIndex < outcomePrices.size) {
|
||||
val price = outcomePrices[outcomeIndex]
|
||||
logger.debug("从 Gamma Market API 获取价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$price")
|
||||
return price
|
||||
}
|
||||
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Gamma Market API 查询异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从 CLOB API 查询订单簿价格
|
||||
|
||||
+9
-7
@@ -54,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)
|
||||
}
|
||||
}
|
||||
|
||||
+100
-17
@@ -3,50 +3,133 @@ package com.wrbug.polymarketbot.service.common
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 频率限制服务(使用内存缓存,全局限制)
|
||||
* 频率限制服务(使用内存缓存)
|
||||
*/
|
||||
@Service
|
||||
class RateLimitService {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
|
||||
|
||||
|
||||
// 重置密码限速配置
|
||||
@Value("\${rate-limit.reset-password.max-attempts:3}")
|
||||
private var maxAttempts: Int = 3
|
||||
|
||||
private var resetPasswordMaxAttempts: Int = 3
|
||||
|
||||
@Value("\${rate-limit.reset-password.window-seconds:60}")
|
||||
private var windowSeconds: Long = 60
|
||||
|
||||
private var resetPasswordWindowSeconds: Long = 60
|
||||
|
||||
// 登录限速配置
|
||||
@Value("\${rate-limit.login.max-attempts:5}")
|
||||
private var loginMaxAttempts: Int = 5
|
||||
|
||||
@Value("\${rate-limit.login.window-seconds:300}")
|
||||
private var loginWindowSeconds: Long = 300 // 5分钟
|
||||
|
||||
@Value("\${rate-limit.login.lockout-seconds:900}")
|
||||
private var loginLockoutSeconds: Long = 900 // 15分钟
|
||||
|
||||
// 全局尝试记录列表(时间戳),所有请求共享
|
||||
private val resetPasswordAttempts = AtomicReference<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 - (windowSeconds * 1000)
|
||||
|
||||
val windowStart = now - (resetPasswordWindowSeconds * 1000)
|
||||
|
||||
// 获取当前尝试记录列表
|
||||
val attempts = resetPasswordAttempts.get()
|
||||
|
||||
|
||||
// 清理过期记录(超过时间窗口的记录)
|
||||
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
|
||||
|
||||
|
||||
// 检查是否超过限制
|
||||
if (validAttempts.size >= maxAttempts) {
|
||||
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts")
|
||||
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试"))
|
||||
if (validAttempts.size >= resetPasswordMaxAttempts) {
|
||||
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts")
|
||||
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试"))
|
||||
}
|
||||
|
||||
|
||||
// 记录本次尝试
|
||||
validAttempts.add(now)
|
||||
resetPasswordAttempts.set(validAttempts)
|
||||
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录频率限制(按IP限制)
|
||||
* @param ipAddress 客户端IP地址
|
||||
* @return Result,如果被锁定或超过限制则返回失败
|
||||
*/
|
||||
fun checkLoginRateLimit(ipAddress: String): Result<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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-13
@@ -9,6 +9,7 @@ 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 com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -18,7 +19,8 @@ import java.math.BigDecimal
|
||||
@Service
|
||||
class CopyTradingFilterService(
|
||||
private val clobService: PolymarketClobService,
|
||||
private val accountService: AccountService
|
||||
private val accountService: AccountService,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
|
||||
@@ -234,32 +236,46 @@ class CopyTradingFilterService(
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue)
|
||||
val currentPositionValue = marketPositions.sumOf { position ->
|
||||
position.currentValue.toSafeBigDecimal()
|
||||
}
|
||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||
val currentPositionValue = dbValue.max(extValue)
|
||||
|
||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
return FilterResult.maxPositionValueFailed(
|
||||
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位)
|
||||
val currentPositionCount = marketPositions.size
|
||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||
|
||||
// 检查:该市场的当前仓位数量 <= 最大仓位数量
|
||||
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
|
||||
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
|
||||
if (currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||
val extCount = positions.currentPositions
|
||||
.filter { it.accountId == copyTrading.accountId }
|
||||
.map { it.marketId }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
val currentPositionCount = maxOf(dbCount, extCount)
|
||||
|
||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
||||
copyTrading.id, marketId, BigDecimal.ZERO
|
||||
)
|
||||
val hasExtPosition = marketPositions.isNotEmpty()
|
||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||
|
||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-16
@@ -14,7 +14,7 @@ import org.springframework.stereotype.Service
|
||||
/**
|
||||
* 跟单监听服务(主服务)
|
||||
* 管理所有Leader的交易监听
|
||||
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
|
||||
* 使用链上 WebSocket 监听 Leader 的交易(实时,秒级延迟)
|
||||
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket)
|
||||
*/
|
||||
@Service
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
) {
|
||||
@@ -51,15 +50,14 @@ class CopyTradingMonitorService(
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
// 停止轮询和链上 WS 监听
|
||||
pollingService.stop()
|
||||
// 停止链上 WS 监听
|
||||
onChainWsService.stop()
|
||||
accountOnChainMonitorService.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
* 启动链上 WebSocket 监听 Leader 的交易(实时,秒级延迟)
|
||||
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
*/
|
||||
suspend fun startMonitoring() {
|
||||
@@ -82,13 +80,9 @@ class CopyTradingMonitorService(
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
}
|
||||
|
||||
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
|
||||
// 4. 启动链上 WebSocket 监听 Leader 的交易(实时,秒级延迟)
|
||||
onChainWsService.start(leaders)
|
||||
|
||||
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
|
||||
pollingService.start(leaders)
|
||||
|
||||
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
accountOnChainMonitorService.start(accounts)
|
||||
}
|
||||
@@ -106,9 +100,8 @@ class CopyTradingMonitorService(
|
||||
return
|
||||
}
|
||||
|
||||
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
|
||||
// 添加到链上 WS 监听(如果不在列表中才添加)
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +117,6 @@ class CopyTradingMonitorService(
|
||||
|
||||
// 没有启用的跟单配置了,移除监听
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +131,6 @@ class CopyTradingMonitorService(
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
// 有启用的跟单配置,确保在监听列表中
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
|
||||
// 更新账户监听(添加该配置关联的账户)
|
||||
val accountIds = copyTradings.map { it.accountId }.distinct()
|
||||
@@ -152,7 +143,6 @@ class CopyTradingMonitorService(
|
||||
} else {
|
||||
// 没有启用的跟单配置,移除监听
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +172,6 @@ class CopyTradingMonitorService(
|
||||
suspend fun restartMonitoring() {
|
||||
// 停止所有监听
|
||||
onChainWsService.stop()
|
||||
pollingService.stop()
|
||||
delay(1000) // 等待1秒
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
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
|
||||
|
||||
/**
|
||||
* 跟单轮询监听服务
|
||||
* 通过定期轮询 Polymarket Data API 的 /activity 接口获取Leader的交易记录
|
||||
* 使用 /activity 接口可以查询用户的链上活动,包括交易
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingPollingService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val templateRepository: CopyTradingTemplateRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingPollingService::class.java)
|
||||
|
||||
@Value("\${copy.trading.polling.interval:2000}")
|
||||
private var pollingInterval: Long = 2000 // 轮询间隔(毫秒),默认2秒
|
||||
|
||||
@Value("\${copy.trading.polling.enabled:true}")
|
||||
private var pollingEnabled: Boolean = true // 是否启用轮询
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 存储需要监听的Leader:leaderId -> Leader
|
||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||
|
||||
// 存储每个Leader已缓存的交易ID集合:leaderId -> Set<tradeId>
|
||||
private val cachedTradeIds = ConcurrentHashMap<Long, MutableSet<String>>()
|
||||
|
||||
// 存储每个Leader是否首次轮询:leaderId -> isFirstPoll
|
||||
private val isFirstPoll = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
// 轮询任务
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
/**
|
||||
* 启动轮询监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
if (!pollingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
|
||||
// 启动轮询任务
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
*/
|
||||
fun addLeader(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id
|
||||
monitoredLeaders[leaderId] = leader
|
||||
// 初始化缓存的交易ID集合
|
||||
cachedTradeIds[leaderId] = mutableSetOf()
|
||||
// 首次轮询标志,用于缓存数据而不处理
|
||||
isFirstPoll[leaderId] = true
|
||||
|
||||
// 如果轮询任务没有运行,启动它
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
cachedTradeIds.remove(leaderId)
|
||||
isFirstPoll.remove(leaderId)
|
||||
|
||||
// 如果没有需要监听的Leader了,停止轮询任务
|
||||
if (monitoredLeaders.isEmpty()) {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有监听
|
||||
*/
|
||||
fun stop() {
|
||||
stopPolling()
|
||||
monitoredLeaders.clear()
|
||||
cachedTradeIds.clear()
|
||||
isFirstPoll.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询任务
|
||||
*/
|
||||
private fun startPolling() {
|
||||
if (pollingJob != null && pollingJob!!.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (monitoredLeaders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
pollingJob = scope.launch {
|
||||
|
||||
while (isActive) {
|
||||
try {
|
||||
// 轮询所有Leader的交易
|
||||
pollAllLeaders()
|
||||
|
||||
// 等待下一次轮询
|
||||
delay(pollingInterval)
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询任务异常", e)
|
||||
delay(pollingInterval) // 异常后继续等待
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询任务
|
||||
*/
|
||||
private fun stopPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询所有Leader的交易
|
||||
*/
|
||||
private suspend fun pollAllLeaders() {
|
||||
val leaders = monitoredLeaders.values.toList()
|
||||
|
||||
if (leaders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 并发轮询所有Leader(限制并发数)
|
||||
leaders.chunked(10).forEach { chunk ->
|
||||
chunk.forEach { leader ->
|
||||
try {
|
||||
pollLeaderTrades(leader)
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询Leader交易失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e)
|
||||
}
|
||||
}
|
||||
// 每个chunk之间稍作延迟,避免API限流
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个Leader的交易
|
||||
* 使用 Polymarket Data API 的 /activity 接口
|
||||
* 通过 diff 分析增量数据,不使用 start 字段
|
||||
*/
|
||||
private suspend fun pollLeaderTrades(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id
|
||||
val leaderAddress = leader.leaderAddress
|
||||
|
||||
try {
|
||||
val firstPoll = isFirstPoll[leaderId] == true
|
||||
val cachedIds = cachedTradeIds[leaderId] ?: mutableSetOf()
|
||||
|
||||
// 创建 Data API 客户端(不需要认证)
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
|
||||
// 查询用户活动(只查询交易类型,不使用 start 字段)
|
||||
// 查询最近的数据(limit=100),通过 diff 找出增量
|
||||
val response: Response<List<UserActivityResponse>> = dataApi.getUserActivity(
|
||||
user = leaderAddress,
|
||||
limit = 100, // 每次最多查询100条
|
||||
offset = 0,
|
||||
type = listOf("TRADE"), // 只查询交易类型
|
||||
start = null, // 不使用 start 字段
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "DESC" // 按时间戳降序,最新的在前
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("获取Leader活动失败: leaderId=$leaderId, address=$leaderAddress, code=${response.code()}, message=${response.message()}")
|
||||
return
|
||||
}
|
||||
|
||||
val activities = response.body()!!
|
||||
|
||||
// 将 UserActivityResponse 转换为 TradeResponse
|
||||
val allTrades = activities.mapNotNull { activity ->
|
||||
// 只处理交易类型
|
||||
if (activity.type != "TRADE" || activity.side == null || activity.price == null || activity.size == null) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
// 转换为 TradeResponse
|
||||
TradeResponse(
|
||||
id = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}",
|
||||
market = activity.conditionId,
|
||||
side = activity.side, // BUY 或 SELL
|
||||
price = activity.price.toString(),
|
||||
size = activity.size.toString(),
|
||||
timestamp = activity.timestamp.toString(), // 时间戳(秒)
|
||||
user = activity.proxyWallet,
|
||||
outcomeIndex = activity.outcomeIndex, // 结果索引(0=YES, 1=NO)
|
||||
outcome = activity.outcome // 结果名称
|
||||
)
|
||||
}
|
||||
|
||||
if (firstPoll) {
|
||||
// 首次轮询:缓存所有查询到的交易ID,不处理
|
||||
val tradeIds = allTrades.map { it.id }.toSet()
|
||||
cachedIds.addAll(tradeIds)
|
||||
cachedTradeIds[leaderId] = cachedIds
|
||||
|
||||
// 标记首次轮询完成
|
||||
isFirstPoll[leaderId] = false
|
||||
} else {
|
||||
// 后续轮询:通过 diff 找出新增的交易
|
||||
val newTradeIds = allTrades.map { it.id }.toSet()
|
||||
val incrementalTradeIds = newTradeIds - cachedIds
|
||||
|
||||
if (incrementalTradeIds.isNotEmpty()) {
|
||||
// 找出新增的交易
|
||||
val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds }
|
||||
|
||||
|
||||
// 处理新增的交易
|
||||
incrementalTrades.forEach { trade ->
|
||||
try {
|
||||
// 检查是否已处理(去重由processTrade内部处理)
|
||||
copyOrderTrackingService.processTrade(leaderId, trade, "polling")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存:添加新增的交易ID
|
||||
cachedIds.addAll(incrementalTradeIds)
|
||||
cachedTradeIds[leaderId] = cachedIds
|
||||
|
||||
} else {
|
||||
}
|
||||
|
||||
// 限制缓存大小,避免内存溢出(只保留最近1000条)
|
||||
if (cachedIds.size > 1000) {
|
||||
// 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可)
|
||||
val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet()
|
||||
cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询Leader交易异常: leaderId=$leaderId, address=$leaderAddress", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -89,6 +89,8 @@ class OnChainWsService(
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
@@ -98,11 +100,13 @@ class OnChainWsService(
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,8 +122,12 @@ class OnChainWsService(
|
||||
}
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||
return
|
||||
}
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
@@ -132,12 +140,15 @@ class OnChainWsService(
|
||||
)
|
||||
|
||||
if (trade != null) {
|
||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||
// 调用 processTrade 处理交易
|
||||
copyOrderTrackingService.processTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
source = "onchain-ws"
|
||||
)
|
||||
} else {
|
||||
logger.warn("无法解析交易(返回 null): leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
|
||||
+1
@@ -207,6 +207,7 @@ object OnChainWsUtils {
|
||||
usdcRaw = usdcIn
|
||||
} else {
|
||||
// 无法判断交易方向
|
||||
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+318
-449
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
@@ -19,6 +20,7 @@ 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.AtomicInteger
|
||||
|
||||
/**
|
||||
* 统一的链上 WebSocket 服务
|
||||
@@ -38,27 +40,8 @@ class UnifiedOnChainWsService(
|
||||
|
||||
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>()
|
||||
// 存储所有地址的连接:address -> AddressWsConnection
|
||||
private val addressConnections = ConcurrentHashMap<String, AddressWsConnection>()
|
||||
|
||||
/**
|
||||
* 订阅信息
|
||||
@@ -88,31 +71,24 @@ class UnifiedOnChainWsService(
|
||||
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
|
||||
): Boolean {
|
||||
try {
|
||||
// 如果已经订阅,先取消
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
unsubscribe(subscriptionId)
|
||||
val lowerAddress = address.lowercase()
|
||||
|
||||
// 找到或创建该地址的连接
|
||||
val connection = addressConnections.computeIfAbsent(lowerAddress) {
|
||||
AddressWsConnection(it).apply { start() }
|
||||
}
|
||||
|
||||
// 创建订阅信息
|
||||
val subscription = SubscriptionInfo(
|
||||
subscriptionId = subscriptionId,
|
||||
address = address.lowercase(),
|
||||
address = lowerAddress,
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
callback = callback
|
||||
)
|
||||
|
||||
subscriptions[subscriptionId] = subscription
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (isConnected) {
|
||||
scope.launch {
|
||||
subscribeAddress(subscription)
|
||||
}
|
||||
} else {
|
||||
// 如果未连接,启动连接
|
||||
startConnection()
|
||||
}
|
||||
// 添加订阅
|
||||
connection.addSubscription(subscription)
|
||||
|
||||
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
|
||||
return true
|
||||
@@ -126,434 +102,37 @@ class UnifiedOnChainWsService(
|
||||
* 取消订阅
|
||||
*/
|
||||
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 (connection in addressConnections.values) {
|
||||
if (connection.hasSubscription(subscriptionId)) {
|
||||
connection.removeSubscription(subscriptionId)
|
||||
|
||||
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 (connection.isSubscriptionsEmpty()) {
|
||||
connection.stop()
|
||||
addressConnections.remove(connection.address)
|
||||
logger.info("连接已无订阅,关闭连接: address=${connection.address}")
|
||||
}
|
||||
|
||||
// 如果已经连接,等待断开
|
||||
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")
|
||||
}
|
||||
}
|
||||
logger.info("取消订阅: subscriptionId=$subscriptionId")
|
||||
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()
|
||||
for (connection in addressConnections.values) {
|
||||
connection.stop()
|
||||
}
|
||||
addressConnections.clear()
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 服务启动时不自动连接,等待有订阅时再连接
|
||||
logger.info("统一链上 WebSocket 服务已初始化")
|
||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
@@ -561,5 +140,295 @@ class UnifiedOnChainWsService(
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个地址的 WebSocket 连接管理
|
||||
*/
|
||||
inner class AddressWsConnection(val address: String) {
|
||||
private var webSocket: WebSocket? = null
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
// 订阅ID计数器(用于请求 ID)
|
||||
private var requestIdCounter = AtomicInteger(0)
|
||||
|
||||
// 连接任务
|
||||
private var connectionJob: Job? = null
|
||||
|
||||
// 该连接下的所有订阅:subscriptionId -> SubscriptionInfo
|
||||
// 理论上一个地址可能被多个业务订阅(如:既是被跟单者又是普通监控),虽然业务上通常只有一个
|
||||
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
|
||||
|
||||
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
|
||||
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
|
||||
|
||||
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
|
||||
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
|
||||
|
||||
fun start() {
|
||||
if (connectionJob != null && connectionJob!!.isActive) return
|
||||
connectionJob = scope.launch {
|
||||
startConnectionLoop()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
webSocket?.close(1000, "停止监听")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
subscriptions.clear()
|
||||
requestIdToSubscriptionId.clear()
|
||||
rpcSubscriptionIdToSubscriptionId.clear()
|
||||
}
|
||||
|
||||
fun addSubscription(subscription: SubscriptionInfo) {
|
||||
// 如果已经存在,先移除旧的
|
||||
removeSubscription(subscription.subscriptionId)
|
||||
subscriptions[subscription.subscriptionId] = subscription
|
||||
|
||||
// 如果已经连接,立即发送链上订阅请求
|
||||
if (isConnected) {
|
||||
scope.launch {
|
||||
subscribeAddressOnChain(subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSubscription(subscriptionId: String) {
|
||||
subscriptions.remove(subscriptionId)
|
||||
// 不需要显式发送 eth_unsubscribe,因为连接是 per-address 的,
|
||||
// 只要只要连接还在,就保持该地址相关的所有 logs 订阅。
|
||||
// 只有当所有 subscription 都移除了,连接才会关闭。
|
||||
}
|
||||
|
||||
fun hasSubscription(subscriptionId: String): Boolean {
|
||||
return subscriptions.containsKey(subscriptionId)
|
||||
}
|
||||
|
||||
fun isSubscriptionsEmpty(): Boolean {
|
||||
return subscriptions.isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
if (subscriptions.isEmpty()) {
|
||||
// 如果启动循环时还没订阅(不太可能,通常是先 addSubscription 再 start,或者是 start 后 addSubscription)
|
||||
// 或者订阅被清空了,外部应当掉 stop,但这里作为防守
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isConnected && webSocket != null) {
|
||||
waitForDisconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取可用的 RPC 节点
|
||||
val wsUrl = rpcNodeService.getWsUrl()
|
||||
val httpUrl = rpcNodeService.getHttpUrl()
|
||||
|
||||
logger.info("[$address] 连接链上 WebSocket: $wsUrl")
|
||||
|
||||
val httpClient = createHttpClient()
|
||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
||||
|
||||
connectWebSocket(wsUrl, httpClient, rpcApi)
|
||||
waitForConnect()
|
||||
|
||||
if (isConnected) {
|
||||
logger.info("[$address] WebSocket 连接已建立,开始注册订阅")
|
||||
// 重新为所有订阅注册链上监听
|
||||
for (subscription in subscriptions.values) {
|
||||
subscribeAddressOnChain(subscription)
|
||||
}
|
||||
waitForDisconnect()
|
||||
}
|
||||
|
||||
logger.info("[$address] WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
|
||||
delay(reconnectDelay)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("[$address] 连接异常: ${e.message}", e)
|
||||
delay(reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("[$address] 链上 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("[$address] 链上 WebSocket 连接关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("[$address] 链上 WebSocket 连接已关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.error("[$address] 链上 WebSocket 连接失败: ${t.message}", t)
|
||||
isConnected = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun subscribeAddressOnChain(subscription: SubscriptionInfo) {
|
||||
if (webSocket == null || !isConnected) return
|
||||
|
||||
val walletTopic = OnChainWsUtils.addressToTopic32(address)
|
||||
val subId = subscription.subscriptionId
|
||||
|
||||
try {
|
||||
// 订阅该地址相关的所有事件
|
||||
// USDC Transfer (from/to)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subId)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subId)
|
||||
|
||||
// ERC1155 TransferSingle (from/to)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subId)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subId)
|
||||
|
||||
// ERC1155 TransferBatch (from/to)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subId)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subId)
|
||||
|
||||
logger.debug("[$address] 已发送链上订阅请求: subscriptionId=$subId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("[$address] 发送链上订阅请求失败: error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeLogs(contractAddress: String, topics: List<String?>, subscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val topicsArray = gson.toJsonTree(topics).asJsonArray
|
||||
val logParams = JsonObject()
|
||||
logParams.addProperty("address", contractAddress.lowercase())
|
||||
logParams.add("topics", topicsArray)
|
||||
|
||||
val requestId = requestIdCounter.incrementAndGet()
|
||||
requestIdToSubscriptionId[requestId] = subscriptionId
|
||||
|
||||
val request = JsonObject()
|
||||
request.addProperty("jsonrpc", "2.0")
|
||||
request.addProperty("id", requestId)
|
||||
request.addProperty("method", "eth_subscribe")
|
||||
val paramsArray = JsonArray()
|
||||
paramsArray.add("logs")
|
||||
paramsArray.add(logParams)
|
||||
request.add("params", paramsArray)
|
||||
|
||||
ws.send(gson.toJson(request))
|
||||
}
|
||||
|
||||
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
val message = gson.fromJson(text, JsonObject::class.java)
|
||||
|
||||
// 1. 处理订阅响应 (eth_subscribe response)
|
||||
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 subId = requestIdToSubscriptionId.remove(requestId)
|
||||
if (subId != null) {
|
||||
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subId
|
||||
logger.debug("[$address] 链上订阅成功: mapped connection rpcSubId=$rpcSubscriptionId to localSubId=$subId")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理日志通知 (eth_subscription)
|
||||
val method = message.get("method")?.asString
|
||||
if (method == "eth_subscription") {
|
||||
val params = message.getAsJsonObject("params") ?: return
|
||||
val rpcSubParam = params.get("subscription")?.asString
|
||||
val result = params.getAsJsonObject("result") ?: return
|
||||
val txHash = result.get("transactionHash")?.asString
|
||||
|
||||
if (txHash != null && rpcSubParam != null) {
|
||||
// 找到触发此通知的本地订阅 ID
|
||||
// 因为我们在这个连接里只订阅了 this.address,所以理论上所有通知都跟这个 address 有关
|
||||
// 但我们需要找到对应的 callback
|
||||
val localSubId = rpcSubscriptionIdToSubscriptionId[rpcSubParam]
|
||||
|
||||
if (localSubId != null) {
|
||||
val subscription = subscriptions[localSubId]
|
||||
if (subscription != null) {
|
||||
logger.info("[$address] 收到交易通知: txHash=$txHash, subId=$localSubId")
|
||||
runCatching {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
}.onFailure { e ->
|
||||
logger.error("[$address] 回调执行失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 找不到具体是哪个订阅请求触发的(可能是重启后之前的订阅残留?或者映射丢失?)
|
||||
// 在单地址单连接模式下,只要是这个 connection 收到的,肯定是关于这个 address 的
|
||||
// 我们可以尝试通知所有订阅者(通常一个地址只有一个订阅者,除非此地址既是Leader又是User)
|
||||
logger.warn("[$address] 未找到映射的订阅ID: rpcSubId=$rpcSubParam. 广播给所有订阅者.")
|
||||
subscriptions.values.forEach { sub ->
|
||||
runCatching {
|
||||
sub.callback(txHash, httpClient, rpcApi)
|
||||
}.onFailure { e ->
|
||||
logger.error("[$address] 广播回调执行失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("[$address] 处理消息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForConnect() {
|
||||
var waited = 0L
|
||||
val timeout = 15000L
|
||||
while (!isConnected && waited < timeout) {
|
||||
delay(100)
|
||||
waited += 100
|
||||
}
|
||||
if (!isConnected) logger.warn("[$address] WebSocket 连接超时")
|
||||
}
|
||||
|
||||
private suspend fun waitForDisconnect() {
|
||||
while (isConnected && scope.isActive) {
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHttpClient(): OkHttpClient {
|
||||
val proxy = getProxyConfig()
|
||||
val builder = createClient()
|
||||
if (proxy != null) builder.proxy(proxy)
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-19
@@ -172,25 +172,15 @@ class OrderSigningService {
|
||||
// 5. 确保 maker 地址也是小写格式
|
||||
val makerAddressLower = makerAddress.lowercase()
|
||||
|
||||
// 打印签名前的订单参数
|
||||
logger.info("========== 订单签名前参数 ==========")
|
||||
logger.info("订单方向: $side")
|
||||
logger.info("价格: $price")
|
||||
logger.info("数量: $size")
|
||||
logger.info("Token ID: $tokenId")
|
||||
logger.info("Maker 地址: $makerAddressLower")
|
||||
logger.info("Signer 地址: $signerAddress")
|
||||
logger.info("Taker 地址: $taker")
|
||||
logger.info("Maker Amount (wei): ${amounts.makerAmount}")
|
||||
logger.info("Taker Amount (wei): ${amounts.takerAmount}")
|
||||
logger.info("Salt: $salt")
|
||||
logger.info("Expiration: $expiration")
|
||||
logger.info("Nonce: $nonce")
|
||||
logger.info("Fee Rate BPS: $feeRateBps")
|
||||
logger.info("Signature Type: $signatureType")
|
||||
logger.info("Exchange Contract: $EXCHANGE_CONTRACT")
|
||||
logger.info("Chain ID: $CHAIN_ID")
|
||||
logger.info("====================================")
|
||||
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露)
|
||||
logger.debug("========== 订单签名前参数 ==========")
|
||||
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
|
||||
logger.debug("Token ID: $tokenId")
|
||||
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
|
||||
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
|
||||
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
val signature = signOrder(
|
||||
|
||||
+204
-129
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* 订单跟踪服务
|
||||
@@ -54,16 +55,16 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 协程作用域(用于异步发送通知)
|
||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
|
||||
// 订单创建重试配置
|
||||
companion object {
|
||||
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
|
||||
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取或创建 Mutex(按交易ID)
|
||||
*/
|
||||
@@ -121,85 +122,85 @@ open class CopyOrderTrackingService(
|
||||
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||
// 获取该交易的 Mutex(按交易ID锁定,不同交易可以并行处理)
|
||||
val mutex = getMutex(leaderId, trade.id)
|
||||
|
||||
|
||||
return mutex.withLock {
|
||||
try {
|
||||
// 1. 检查是否已处理(去重,包括失败状态)
|
||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
// 1. 检查是否已处理(去重,包括失败状态)
|
||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
|
||||
if (existingProcessed != null) {
|
||||
if (existingProcessed.status == "FAILED") {
|
||||
if (existingProcessed != null) {
|
||||
if (existingProcessed.status == "FAILED") {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
logger.error(
|
||||
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
|
||||
result.exceptionOrNull()
|
||||
)
|
||||
return@withLock result
|
||||
}
|
||||
|
||||
// 3. 标记为已处理(成功状态)
|
||||
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
|
||||
try {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
tradeType = trade.side.uppercase(),
|
||||
source = source,
|
||||
status = "SUCCESS",
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
|
||||
delay(100)
|
||||
val existingAfterDelay =
|
||||
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existingAfterDelay != null) {
|
||||
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.warn(
|
||||
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
} else {
|
||||
// 其他类型的异常,重新抛出
|
||||
throw e
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
Result.failure(e)
|
||||
// 2. 处理交易逻辑
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
logger.error(
|
||||
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
|
||||
result.exceptionOrNull()
|
||||
)
|
||||
return@withLock result
|
||||
}
|
||||
|
||||
// 3. 标记为已处理(成功状态)
|
||||
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
|
||||
try {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
tradeType = trade.side.uppercase(),
|
||||
source = source,
|
||||
status = "SUCCESS",
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
|
||||
delay(100)
|
||||
val existingAfterDelay =
|
||||
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existingAfterDelay != null) {
|
||||
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.warn(
|
||||
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
} else {
|
||||
// 其他类型的异常,重新抛出
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,13 +254,13 @@ open class CopyOrderTrackingService(
|
||||
// 先计算跟单金额(用于仓位检查)
|
||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val buyQuantity = try {
|
||||
var buyQuantity = try {
|
||||
calculateBuyQuantity(trade, copyTrading)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算买入数量失败: ${e.message}", e)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 计算跟单金额(USDC)= 买入数量 × 价格
|
||||
val copyOrderAmount = buyQuantity.multi(tradePrice)
|
||||
|
||||
@@ -268,8 +269,8 @@ open class CopyOrderTrackingService(
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
||||
// 订单簿只请求一次,返回给后续逻辑使用
|
||||
val filterResult = filterService.checkFilters(
|
||||
copyTrading,
|
||||
tokenId,
|
||||
copyTrading,
|
||||
tokenId,
|
||||
tradePrice = tradePrice,
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market
|
||||
@@ -369,22 +370,72 @@ open class CopyOrderTrackingService(
|
||||
// 买入数量已在过滤检查前计算,这里直接使用
|
||||
// 如果数量为0或负数,跳过
|
||||
if (buyQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("计算出的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
logger.warn("计算得到的买入数量为0,跳过跟单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
if (buyQuantity.lt(BigDecimal.ONE)) {
|
||||
logger.warn("计算得到的买入数量小于1,自动调整为1 (Polymarket 最小下单数量): copyTradingId=${copyTrading.id}, tradeId=${trade.id}, originalQuantity=$buyQuantity")
|
||||
buyQuantity = BigDecimal.ONE
|
||||
}
|
||||
// 验证订单数量限制(仅比例模式)
|
||||
var finalBuyQuantity = buyQuantity
|
||||
if (copyTrading.copyMode == "RATIO") {
|
||||
val orderAmount = buyQuantity.multi(trade.price.toSafeBigDecimal())
|
||||
if (orderAmount.lt(copyTrading.minOrderSize)) {
|
||||
logger.warn("订单金额低于最小限制,跳过: copyTradingId=${copyTrading.id}, amount=$orderAmount, min=${copyTrading.minOrderSize}")
|
||||
continue
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val rawOrderAmount = buyQuantity.multi(tradePrice)
|
||||
|
||||
// 对按比例计算的金额进行向上取整处理(确保满足最小限制)
|
||||
// 向上取整到 2 位小数(USDC 精度)
|
||||
val roundedOrderAmount = rawOrderAmount.setScale(2, java.math.RoundingMode.CEILING)
|
||||
|
||||
// 如果原始金额或向上取整后的金额小于最小限制,调整 buyQuantity 以满足最小限制
|
||||
// 这样可以避免精度问题导致订单被错误地跳过
|
||||
if (roundedOrderAmount.lt(copyTrading.minOrderSize)) {
|
||||
logger.debug("订单金额(向上取整后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, roundedAmount=$roundedOrderAmount, min=${copyTrading.minOrderSize}")
|
||||
// 计算满足最小限制所需的数量(向上取整)
|
||||
val minQuantity =
|
||||
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
|
||||
if (minQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
|
||||
continue
|
||||
}
|
||||
// 使用调整后的数量
|
||||
finalBuyQuantity = minQuantity
|
||||
logger.debug(
|
||||
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
|
||||
finalBuyQuantity.multi(
|
||||
tradePrice
|
||||
)
|
||||
}"
|
||||
)
|
||||
} else if (rawOrderAmount.lt(copyTrading.minOrderSize)) {
|
||||
// 原始金额小于最小限制,但向上取整后满足,调整数量以满足最小限制
|
||||
logger.debug("订单金额(精度处理后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, min=${copyTrading.minOrderSize}")
|
||||
// 计算满足最小限制所需的数量(向上取整)
|
||||
val minQuantity =
|
||||
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
|
||||
if (minQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
|
||||
continue
|
||||
}
|
||||
// 使用调整后的数量
|
||||
finalBuyQuantity = minQuantity
|
||||
logger.debug(
|
||||
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
|
||||
finalBuyQuantity.multi(
|
||||
tradePrice
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
if (orderAmount.gt(copyTrading.maxOrderSize)) {
|
||||
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$orderAmount, max=${copyTrading.maxOrderSize}")
|
||||
|
||||
// 检查最大限制(使用调整后的数量)
|
||||
val finalOrderAmount = finalBuyQuantity.multi(tradePrice)
|
||||
if (finalOrderAmount.gt(copyTrading.maxOrderSize)) {
|
||||
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$finalOrderAmount, max=${copyTrading.maxOrderSize}")
|
||||
// 调整数量到最大值
|
||||
val adjustedQuantity = copyTrading.maxOrderSize.div(trade.price.toSafeBigDecimal())
|
||||
val adjustedQuantity =
|
||||
copyTrading.maxOrderSize.div(tradePrice, 8, java.math.RoundingMode.DOWN)
|
||||
if (adjustedQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("调整后的数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
|
||||
continue
|
||||
@@ -409,7 +460,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 计算价格(应用价格容忍度)
|
||||
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
|
||||
|
||||
logger.debug("计算价格结果:$buyPrice")
|
||||
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
|
||||
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
|
||||
val orderbookForCheck = orderbook ?: run {
|
||||
@@ -420,21 +471,21 @@ open class CopyOrderTrackingService(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 检查是否有可匹配的卖单(asks)
|
||||
if (orderbookForCheck != null) {
|
||||
val bestAsk = orderbookForCheck.asks
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.minOrNull()
|
||||
|
||||
|
||||
if (bestAsk == null) {
|
||||
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
|
||||
if (buyPrice.lt(bestAsk)) {
|
||||
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
|
||||
logger.info("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -464,6 +515,8 @@ open class CopyOrderTrackingService(
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
|
||||
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||
@@ -484,6 +537,7 @@ open class CopyOrderTrackingService(
|
||||
if (createOrderResult.isFailure) {
|
||||
// 提取错误信息(只保留 code 和 errorBody)
|
||||
val exception = createOrderResult.exceptionOrNull()
|
||||
logger.error("创建买入订单失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, myPrice=$buyPrice, error=${exception?.message}")
|
||||
|
||||
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
|
||||
if (copyTrading.pushFailedOrders) {
|
||||
@@ -539,7 +593,7 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
val realOrderId = createOrderResult.getOrNull() ?: continue
|
||||
|
||||
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(realOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,跳过保存: orderId=$realOrderId")
|
||||
@@ -625,8 +679,8 @@ open class CopyOrderTrackingService(
|
||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||
return when (copyTrading.copyMode) {
|
||||
"RATIO" -> {
|
||||
// 比例模式:Leader 数量 × 比例
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
// 比例模式:Leader 数量 × (比例 / 100)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
"FIXED" -> {
|
||||
@@ -658,7 +712,7 @@ open class CopyOrderTrackingService(
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
?: run {
|
||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||
@@ -677,7 +731,7 @@ open class CopyOrderTrackingService(
|
||||
for (order in unmatchedOrders) {
|
||||
val copyQty = order.quantity.toSafeBigDecimal()
|
||||
var leaderQty: BigDecimal? = null
|
||||
|
||||
|
||||
// 优先使用存储的 leaderBuyQuantity
|
||||
if (order.leaderBuyQuantity != null) {
|
||||
leaderQty = order.leaderBuyQuantity.toSafeBigDecimal()
|
||||
@@ -688,7 +742,7 @@ open class CopyOrderTrackingService(
|
||||
logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}")
|
||||
try {
|
||||
val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId)
|
||||
|
||||
|
||||
if (tradesResponse.isSuccessful && tradesResponse.body() != null) {
|
||||
val tradesData = tradesResponse.body()!!.data
|
||||
if (tradesData.isNotEmpty()) {
|
||||
@@ -714,7 +768,7 @@ open class CopyOrderTrackingService(
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果成功获取到 Leader 买入数量,累加
|
||||
if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) {
|
||||
totalCopyQuantity = totalCopyQuantity.add(copyQty)
|
||||
@@ -723,23 +777,23 @@ open class CopyOrderTrackingService(
|
||||
logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity")
|
||||
|
||||
// 如果无法计算总比例(查询失败),使用默认比例
|
||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||
val actualRatio = totalCopyQuantity.div(totalLeaderQuantity)
|
||||
|
||||
|
||||
// 计算需要卖出的数量:Leader 卖出数量 × 实际比例
|
||||
val needMatch = leaderSellQuantity.multi(actualRatio)
|
||||
|
||||
|
||||
logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch")
|
||||
|
||||
|
||||
return needMatch
|
||||
}
|
||||
|
||||
@@ -803,16 +857,26 @@ open class CopyOrderTrackingService(
|
||||
copyTrading = copyTrading
|
||||
)
|
||||
}
|
||||
|
||||
"RATIO" -> {
|
||||
// 比例模式:直接使用配置的 copyRatio
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果需要卖出的数量小于1(但大于0),自动调整为1(Polymarket 最小下单数量)
|
||||
// 注意:如果实际持有数量不足1,后续的 totalMatched 检查会拦截
|
||||
var finalNeedMatch = needMatch
|
||||
if (finalNeedMatch.gt(BigDecimal.ZERO) && finalNeedMatch.lt(BigDecimal.ONE)) {
|
||||
logger.warn("计算得到的卖出数量小于1,自动调整为1: copyTradingId=${copyTrading.id}, original=$needMatch")
|
||||
finalNeedMatch = BigDecimal.ONE
|
||||
}
|
||||
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
@@ -836,7 +900,7 @@ open class CopyOrderTrackingService(
|
||||
// 6. 按FIFO顺序匹配,计算实际可以卖出的数量
|
||||
// 使用计算出的实际卖出价格(而不是 Leader 价格)来创建匹配明细
|
||||
var totalMatched = BigDecimal.ZERO
|
||||
var remaining = needMatch
|
||||
var remaining = finalNeedMatch
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
|
||||
for (order in unmatchedOrders) {
|
||||
@@ -873,6 +937,11 @@ open class CopyOrderTrackingService(
|
||||
return
|
||||
}
|
||||
|
||||
if (totalMatched.lt(BigDecimal.ONE)) {
|
||||
logger.warn("卖出数量小于1,跳过卖出 (Polymarket 最小下单数量为 1): copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, quantity=$totalMatched")
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
decryptApiSecret(account)
|
||||
@@ -889,7 +958,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 8. 解密私钥(在方法开始时解密一次,后续复用)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
@@ -959,7 +1028,7 @@ open class CopyOrderTrackingService(
|
||||
} else {
|
||||
logger.debug("卖出订单ID为0x开头,等待定时任务更新价格: orderId=$realSellOrderId")
|
||||
}
|
||||
|
||||
|
||||
// 使用下单价格,等待定时任务更新实际成交价
|
||||
val actualSellPrice = sellPrice
|
||||
|
||||
@@ -1008,19 +1077,19 @@ open class CopyOrderTrackingService(
|
||||
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
|
||||
sellMatchDetailRepository.save(savedDetail)
|
||||
}
|
||||
|
||||
|
||||
logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}")
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单(带重试机制)
|
||||
*
|
||||
*
|
||||
* 重试策略:
|
||||
* - 最多重试 MAX_RETRY_ATTEMPTS 次(首次尝试 + 重试)
|
||||
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
* - 每次重试都重新生成salt并重新签名,确保签名唯一性
|
||||
*
|
||||
*
|
||||
* @param clobApi CLOB API 客户端
|
||||
* @param privateKey 私钥(用于签名)
|
||||
* @param makerAddress 代理钱包地址
|
||||
@@ -1086,10 +1155,10 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
|
||||
lastError = Exception(errorMsg)
|
||||
|
||||
|
||||
// 记录错误日志
|
||||
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
|
||||
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
@@ -1103,10 +1172,10 @@ open class CopyOrderTrackingService(
|
||||
if (!response.success || response.orderId == null) {
|
||||
val errorMsg = "errorMsg=${response.errorMsg}"
|
||||
lastError = Exception(errorMsg)
|
||||
|
||||
|
||||
// 记录错误日志
|
||||
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
|
||||
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
@@ -1118,14 +1187,17 @@ open class CopyOrderTrackingService(
|
||||
// 创建订单成功
|
||||
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
|
||||
return Result.success(response.orderId)
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = "error=${e.message}"
|
||||
lastError = Exception(errorMsg, e)
|
||||
|
||||
|
||||
// 记录错误日志(包含堆栈)
|
||||
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
|
||||
|
||||
logger.error(
|
||||
"创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg",
|
||||
e
|
||||
)
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
@@ -1137,7 +1209,10 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 所有重试都失败
|
||||
val finalError = lastError ?: Exception("error=未知错误")
|
||||
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
|
||||
logger.error(
|
||||
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size",
|
||||
finalError
|
||||
)
|
||||
return Result.failure(finalError)
|
||||
}
|
||||
|
||||
@@ -1272,7 +1347,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 计算价格调整范围(百分比)
|
||||
val tolerancePercent = tolerance.div(100)
|
||||
val adjustment = originalPrice.multi(tolerancePercent)
|
||||
val adjustment = originalPrice.multi(tolerancePercent).max(0.01.toSafeBigDecimal())
|
||||
|
||||
return if (isBuy) {
|
||||
// 买入:可以稍微加价以确保成交(在原价格基础上加容忍度)
|
||||
@@ -1325,7 +1400,7 @@ open class CopyOrderTrackingService(
|
||||
/**
|
||||
* 验证订单ID格式
|
||||
* 订单ID必须以 0x 开头,且是有效的 16 进制字符串
|
||||
*
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @return 如果格式有效返回 true,否则返回 false
|
||||
*/
|
||||
@@ -1341,11 +1416,11 @@ open class CopyOrderTrackingService(
|
||||
// 检查是否只包含 0-9, a-f, A-F
|
||||
return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取订单的实际成交价
|
||||
* 通过查询订单详情和关联的交易记录,计算加权平均成交价
|
||||
*
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @param clobApi CLOB API 客户端(已认证)
|
||||
* @param fallbackPrice 如果查询失败,使用此价格作为默认值
|
||||
@@ -1364,14 +1439,14 @@ open class CopyOrderTrackingService(
|
||||
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||
return fallbackPrice
|
||||
}
|
||||
|
||||
|
||||
val order = orderResponse.body()
|
||||
if (order == null) {
|
||||
// 响应体为空,可能是订单不存在或已过期
|
||||
logger.warn("查询订单详情失败: 响应体为空, orderId=$orderId, code=${orderResponse.code()}")
|
||||
return fallbackPrice
|
||||
}
|
||||
|
||||
|
||||
// 2. 如果订单未成交,使用下单价格
|
||||
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
|
||||
logger.debug("订单未成交,使用下单价格: orderId=$orderId, status=${order.status}")
|
||||
@@ -1412,7 +1487,7 @@ open class CopyOrderTrackingService(
|
||||
for (trade in trades) {
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val tradeSize = trade.size.toSafeBigDecimal()
|
||||
|
||||
|
||||
if (tradeSize > BigDecimal.ZERO) {
|
||||
totalAmount = totalAmount.add(tradePrice.multiply(tradeSize))
|
||||
totalSize = totalSize.add(tradeSize)
|
||||
|
||||
+41
-17
@@ -143,8 +143,13 @@ class OrderStatusUpdateService(
|
||||
// 计算30秒前的时间戳
|
||||
val thirtySecondsAgo = System.currentTimeMillis() - 30000
|
||||
|
||||
// 查询30秒前创建的订单
|
||||
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
|
||||
// 查询30秒前创建的订单,并过滤掉已经完全匹配的订单
|
||||
// 已经完全匹配的订单(status = "fully_matched")不需要再检查
|
||||
// 使用数据库查询过滤,避免加载过多数据
|
||||
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBeforeAndStatusNot(
|
||||
thirtySecondsAgo,
|
||||
"fully_matched"
|
||||
)
|
||||
|
||||
if (ordersToCheck.isEmpty()) {
|
||||
return
|
||||
@@ -199,22 +204,29 @@ class OrderStatusUpdateService(
|
||||
// 查询订单详情
|
||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||
|
||||
if (!orderResponse.isSuccessful) {
|
||||
// HTTP 错误,可能是订单不存在,删除
|
||||
logger.info("订单查询失败(HTTP错误),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}")
|
||||
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)
|
||||
}
|
||||
// 先检查 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 但响应体为空,表示订单不存在,删除
|
||||
logger.info("订单不存在(响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}")
|
||||
// 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}")
|
||||
@@ -577,16 +589,28 @@ class OrderStatusUpdateService(
|
||||
|
||||
// 查询订单详情
|
||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||
if (!orderResponse.isSuccessful) {
|
||||
|
||||
// 先检查 HTTP 状态码,非 200 的都跳过
|
||||
if (orderResponse.code() != 200) {
|
||||
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
|
||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||
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 但响应体为空,表示订单不存在(没有交易成功),删除本地订单
|
||||
logger.info("订单不存在(响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}")
|
||||
// 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}")
|
||||
|
||||
+4
-15
@@ -333,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 前缀
|
||||
@@ -364,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)
|
||||
|
||||
+20
-7
@@ -25,6 +25,19 @@ class SystemConfigService(
|
||||
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
||||
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
||||
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
||||
|
||||
/**
|
||||
* 遮蔽敏感信息,仅显示前4位和后4位
|
||||
* 例如:abcd1234...wxyz5678
|
||||
*/
|
||||
fun maskSensitiveValue(value: String?): String? {
|
||||
if (value == null) return null
|
||||
return when {
|
||||
value.length <= 8 -> "****" // 太短则完全遮蔽
|
||||
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
|
||||
else -> "${value.take(4)}...${value.takeLast(4)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,26 +49,26 @@ class SystemConfigService(
|
||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||
val autoRedeem = isAutoRedeemEnabled()
|
||||
|
||||
// 获取完整的 API Key(用于前端展示)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val builderSecretDisplay = builderSecret?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||
try {
|
||||
cryptoUtils.decrypt(it)
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
+48
-2
@@ -43,6 +43,25 @@ class TelegramNotificationService(
|
||||
// 协程作用域
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/**
|
||||
* 发送订单成功通知
|
||||
* @param orderId 订单ID(用于查询订单详情获取实际价格和数量)
|
||||
* @param marketTitle 市场标题
|
||||
* @param marketId 市场ID(conditionId),用于生成链接
|
||||
* @param marketSlug 市场slug,用于生成链接
|
||||
* @param side 订单方向(BUY/SELL),用于多语言显示
|
||||
* @param accountName 账户名称
|
||||
* @param walletAddress 钱包地址
|
||||
* @param clobApi CLOB API 客户端(可选,如果提供则查询订单详情获取实际价格和数量)
|
||||
* @param apiKey API Key(可选,用于查询订单详情)
|
||||
* @param apiSecret API Secret(可选,用于查询订单详情)
|
||||
* @param apiPassphrase API Passphrase(可选,用于查询订单详情)
|
||||
* @param walletAddressForApi 钱包地址(可选,用于查询订单详情)
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
*/
|
||||
// 已发送通知的订单ID缓存(key: orderId, value: timestamp)
|
||||
private val sentOrderIds = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
|
||||
/**
|
||||
* 发送订单成功通知
|
||||
* @param orderId 订单ID(用于查询订单详情获取实际价格和数量)
|
||||
@@ -79,6 +98,26 @@ class TelegramNotificationService(
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null // 跟单配置名
|
||||
) {
|
||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||
if (orderId != null) {
|
||||
val lastSentTime = sentOrderIds[orderId]
|
||||
if (lastSentTime != null) {
|
||||
// 如果5分钟内已发送过,跳过
|
||||
if (System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||
logger.info("订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||
return
|
||||
}
|
||||
}
|
||||
// 记录发送时间
|
||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||
|
||||
// 简单的清理逻辑:如果缓存过大,清理过期的
|
||||
if (sentOrderIds.size > 1000) {
|
||||
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
|
||||
sentOrderIds.entries.removeIf { it.value < expiryTime }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
@@ -686,6 +725,13 @@ class TelegramNotificationService(
|
||||
else -> side
|
||||
}
|
||||
|
||||
// 获取图标
|
||||
val icon = when (side.uppercase()) {
|
||||
"BUY" -> "🚀"
|
||||
"SELL" -> "💰"
|
||||
else -> "📣"
|
||||
}
|
||||
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
@@ -761,7 +807,7 @@ class TelegramNotificationService(
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """✅ <b>$orderCreatedSuccess</b>
|
||||
return """$icon <b>$orderCreatedSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
@@ -989,7 +1035,7 @@ class TelegramNotificationService(
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
||||
}
|
||||
|
||||
return """✅ <b>$redeemSuccess</b>
|
||||
return """💸 <b>$redeemSuccess</b>
|
||||
|
||||
📊 <b>$redeemInfo:</b>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -138,16 +138,48 @@ object EthereumUtils {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ============================================
|
||||
-- 添加 wallet_type 字段到 wallet_accounts 表
|
||||
-- 用于区分 Magic 和 Safe 两种钱包类型
|
||||
-- ============================================
|
||||
|
||||
-- 使用存储过程检查并添加字段(如果不存在)
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE IF NOT EXISTS add_wallet_type_if_not_exists()
|
||||
BEGIN
|
||||
DECLARE column_exists INT DEFAULT 0;
|
||||
|
||||
SELECT COUNT(*) INTO column_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'wallet_accounts'
|
||||
AND COLUMN_NAME = 'wallet_type';
|
||||
|
||||
IF column_exists = 0 THEN
|
||||
ALTER TABLE wallet_accounts
|
||||
ADD COLUMN wallet_type VARCHAR(20) NOT NULL DEFAULT 'magic' COMMENT '钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)' AFTER is_enabled;
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 执行存储过程
|
||||
CALL add_wallet_type_if_not_exists();
|
||||
|
||||
-- 删除存储过程
|
||||
DROP PROCEDURE IF EXISTS add_wallet_type_if_not_exists;
|
||||
|
||||
-- 为现有账户设置默认walletType为magic(如果值为NULL)
|
||||
UPDATE wallet_accounts SET wallet_type = 'magic' WHERE wallet_type IS NULL OR wallet_type = '';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ============================================
|
||||
-- V18: 增加 copy_ratio 字段的精度,支持更小的小数值
|
||||
-- ============================================
|
||||
|
||||
-- 修改 copy_trading 表的 copy_ratio 字段精度
|
||||
-- 从 DECIMAL(10, 2) 改为 DECIMAL(20, 8),支持更小的跟单比例值(如 0.001)
|
||||
ALTER TABLE copy_trading
|
||||
MODIFY COLUMN copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '跟单比例(仅在copyMode=RATIO时生效)';
|
||||
|
||||
-- 修改 copy_trading_templates 表的 copy_ratio 字段精度
|
||||
-- 从 DECIMAL(10, 2) 改为 DECIMAL(20, 8),支持更小的跟单比例值(如 0.001)
|
||||
ALTER TABLE copy_trading_templates
|
||||
MODIFY COLUMN copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '跟单比例(仅在copyMode=RATIO时生效)';
|
||||
|
||||
@@ -833,3 +833,4 @@ fun analyzeTrader(@RequestBody request: SmartMoneyAnalyzeRequest): ResponseEntit
|
||||
4. **风险预警**:监控聪明钱交易者的风险指标,及时预警
|
||||
5. **多维度分析**:增加更多分析维度(如市场类型、时间分布等)
|
||||
|
||||
|
||||
|
||||
Generated
+18
-5
@@ -11,7 +11,7 @@
|
||||
"antd": "^5.12.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"axios": "^1.6.2",
|
||||
"ethers": "^6.9.0",
|
||||
"ethers": "^6.16.0",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -158,6 +158,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1676,6 +1677,7 @@
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1741,6 +1743,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
@@ -1937,6 +1940,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2255,6 +2259,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -2466,7 +2471,8 @@
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -2687,6 +2693,7 @@
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -2877,9 +2884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ethers": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz",
|
||||
"integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
|
||||
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2890,6 +2897,7 @@
|
||||
"url": "https://www.buymeacoffee.com/ricmoo"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adraffy/ens-normalize": "1.10.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
@@ -3364,6 +3372,7 @@
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -5432,6 +5441,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -5443,6 +5453,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6010,6 +6021,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6182,6 +6194,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"antd": "^5.12.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"axios": "^1.6.2",
|
||||
"ethers": "^6.9.0",
|
||||
"ethers": "^6.16.0",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Radio, Space, Alert } from 'antd'
|
||||
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import {
|
||||
getAddressFromPrivateKey,
|
||||
import {
|
||||
getAddressFromPrivateKey,
|
||||
getAddressFromMnemonic,
|
||||
getPrivateKeyFromMnemonic,
|
||||
isValidWalletAddress,
|
||||
isValidWalletAddress,
|
||||
isValidPrivateKey,
|
||||
isValidMnemonic
|
||||
} from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
type WalletType = 'magic' | 'safe'
|
||||
|
||||
interface AccountImportFormProps {
|
||||
form: any
|
||||
@@ -33,6 +35,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { importAccount, loading } = useAccountStore()
|
||||
const [importType, setImportType] = useState<ImportType>('privateKey')
|
||||
const [walletType, setWalletType] = useState<WalletType>('safe')
|
||||
const [derivedAddress, setDerivedAddress] = useState<string>('')
|
||||
const [addressError, setAddressError] = useState<string>('')
|
||||
|
||||
@@ -141,7 +144,8 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
await importAccount({
|
||||
privateKey: privateKey,
|
||||
walletAddress: walletAddress,
|
||||
accountName: values.accountName
|
||||
accountName: values.accountName,
|
||||
walletType: walletType
|
||||
})
|
||||
|
||||
// 等待store更新
|
||||
@@ -189,8 +193,8 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item label={t('accountImport.importMethod')}>
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value)
|
||||
setDerivedAddress('')
|
||||
@@ -202,6 +206,32 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{t('accountImport.walletType')}{' '}
|
||||
<Tooltip
|
||||
title={t('accountImport.walletTypeHelp')}
|
||||
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
|
||||
>
|
||||
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Radio.Group
|
||||
value={walletType}
|
||||
onChange={(e) => setWalletType(e.target.value)}
|
||||
>
|
||||
<Radio value="safe">
|
||||
{t('accountImport.walletTypeSafe')}
|
||||
</Radio>
|
||||
<Radio value="magic">
|
||||
{t('accountImport.walletTypeMagic')}
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{importType === 'privateKey' ? (
|
||||
<>
|
||||
|
||||
@@ -199,7 +199,11 @@
|
||||
"importFailed": "Failed to import account",
|
||||
"derivedAddress": "Derived Address",
|
||||
"addressError": "Cannot derive address from private key",
|
||||
"addressErrorMnemonic": "Cannot derive address from mnemonic"
|
||||
"addressErrorMnemonic": "Cannot derive address from mnemonic",
|
||||
"walletType": "Wallet Type",
|
||||
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
|
||||
"walletTypeMagic": "Magic (Email/Social Login)",
|
||||
"walletTypeSafe": "Web3 Wallet"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader Management",
|
||||
|
||||
@@ -199,7 +199,11 @@
|
||||
"importFailed": "导入账户失败",
|
||||
"derivedAddress": "推导地址",
|
||||
"addressError": "无法从私钥推导地址",
|
||||
"addressErrorMnemonic": "无法从助记词推导地址"
|
||||
"addressErrorMnemonic": "无法从助记词推导地址",
|
||||
"walletType": "钱包类型",
|
||||
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
|
||||
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
|
||||
"walletTypeSafe": "Web3钱包"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
@@ -1022,7 +1026,7 @@
|
||||
"remainingQuantity": "剩余",
|
||||
"sellStatus": "卖出状态",
|
||||
"status": "状态",
|
||||
"statusFilled": "已完成",
|
||||
"statusFilled": "未成交",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"realizedPnl": "已实现盈亏",
|
||||
|
||||
@@ -199,7 +199,11 @@
|
||||
"importFailed": "導入賬戶失敗",
|
||||
"derivedAddress": "推導地址",
|
||||
"addressError": "無法從私鑰推導地址",
|
||||
"addressErrorMnemonic": "無法從助記詞推導地址"
|
||||
"addressErrorMnemonic": "無法從助記詞推導地址",
|
||||
"walletType": "錢包類型",
|
||||
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
|
||||
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
|
||||
"walletTypeSafe": "Web3錢包"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd'
|
||||
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -7,12 +6,12 @@ import { useAccountStore } from '../store/accountStore'
|
||||
import type { Account } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
import AccountImportForm from '../components/AccountImportForm'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const AccountList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, loading, fetchAccounts, deleteAccount, fetchAccountBalance, fetchAccountDetail, updateAccount } = useAccountStore()
|
||||
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
|
||||
@@ -25,11 +24,20 @@ const AccountList: React.FC = () => {
|
||||
const [editAccount, setEditAccount] = useState<Account | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
||||
const [accountImportForm] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
const handleAccountImportSuccess = async () => {
|
||||
message.success(t('accountImport.importSuccess'))
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
// 加载所有账户的余额
|
||||
useEffect(() => {
|
||||
const loadBalances = async () => {
|
||||
@@ -494,7 +502,7 @@ const AccountList: React.FC = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/accounts/import')}
|
||||
onClick={() => setAccountImportModalVisible(true)}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
block={isMobile}
|
||||
style={isMobile ? { minHeight: '44px' } : undefined}
|
||||
@@ -836,6 +844,34 @@ const AccountList: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 导入账户 Modal */}
|
||||
<Modal
|
||||
title={t('accountImport.title')}
|
||||
open={accountImportModalVisible}
|
||||
onCancel={() => {
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
>
|
||||
<AccountImportForm
|
||||
form={accountImportForm}
|
||||
onSuccess={handleAccountImportSuccess}
|
||||
onCancel={() => {
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -344,13 +344,51 @@ const CopyTradingAdd: React.FC = () => {
|
||||
tooltip={t('copyTradingAdd.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单'}
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('copyTradingAdd.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单),默认 100%'}
|
||||
parser={(value) => {
|
||||
console.log('[CopyTradingAdd copyRatio parser] 输入值:', value, '类型:', typeof value)
|
||||
// 移除 % 符号和其他非数字字符(保留小数点和负号)
|
||||
const cleaned = (value || '').toString().replace(/%/g, '').trim()
|
||||
console.log('[CopyTradingAdd copyRatio parser] 清理后:', cleaned)
|
||||
const parsed = parseFloat(cleaned) || 0
|
||||
console.log('[CopyTradingAdd copyRatio parser] 解析后:', parsed)
|
||||
if (parsed > 10000) {
|
||||
console.log('[CopyTradingAdd copyRatio parser] 超过最大值,返回 10000')
|
||||
return 10000
|
||||
}
|
||||
if (parsed < 0.01) {
|
||||
console.log('[CopyTradingAdd copyRatio parser] 小于最小值,返回 0.01')
|
||||
return 0.01
|
||||
}
|
||||
console.log('[CopyTradingAdd copyRatio parser] 返回:', parsed)
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
console.log('[CopyTradingAdd copyRatio formatter] 输入值:', value, '类型:', typeof value)
|
||||
if (!value && value !== 0) {
|
||||
console.log('[CopyTradingAdd copyRatio formatter] 空值,返回空字符串')
|
||||
return ''
|
||||
}
|
||||
const num = parseFloat(value.toString())
|
||||
console.log('[CopyTradingAdd copyRatio formatter] 解析后:', num)
|
||||
if (isNaN(num)) {
|
||||
console.log('[CopyTradingAdd copyRatio formatter] NaN,返回空字符串')
|
||||
return ''
|
||||
}
|
||||
if (num > 10000) {
|
||||
console.log('[CopyTradingAdd copyRatio formatter] 超过最大值,返回 10000')
|
||||
return '10000'
|
||||
}
|
||||
const result = num.toString().replace(/\.0+$/, '')
|
||||
console.log('[CopyTradingAdd copyRatio formatter] 格式化后返回:', result)
|
||||
return result
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -383,6 +421,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -400,6 +444,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -427,6 +477,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -443,6 +499,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -471,6 +533,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.priceTolerancePlaceholder') || '默认 5%(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -498,6 +566,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -512,6 +586,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -531,6 +611,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingAdd.minPricePlaceholder') || '最低价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
@@ -542,6 +628,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingAdd.maxPricePlaceholder') || '最高价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
@@ -560,6 +652,12 @@ const CopyTradingAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -237,13 +237,51 @@ const CopyTradingEdit: React.FC = () => {
|
||||
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单)'}
|
||||
parser={(value) => {
|
||||
console.log('[CopyTradingEdit copyRatio parser] 输入值:', value, '类型:', typeof value)
|
||||
// 移除 % 符号和其他非数字字符(保留小数点和负号)
|
||||
const cleaned = (value || '').toString().replace(/%/g, '').trim()
|
||||
console.log('[CopyTradingEdit copyRatio parser] 清理后:', cleaned)
|
||||
const parsed = parseFloat(cleaned) || 0
|
||||
console.log('[CopyTradingEdit copyRatio parser] 解析后:', parsed)
|
||||
if (parsed > 10000) {
|
||||
console.log('[CopyTradingEdit copyRatio parser] 超过最大值,返回 10000')
|
||||
return 10000
|
||||
}
|
||||
if (parsed < 0.01) {
|
||||
console.log('[CopyTradingEdit copyRatio parser] 小于最小值,返回 0.01')
|
||||
return 0.01
|
||||
}
|
||||
console.log('[CopyTradingEdit copyRatio parser] 返回:', parsed)
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
console.log('[CopyTradingEdit copyRatio formatter] 输入值:', value, '类型:', typeof value)
|
||||
if (!value && value !== 0) {
|
||||
console.log('[CopyTradingEdit copyRatio formatter] 空值,返回空字符串')
|
||||
return ''
|
||||
}
|
||||
const num = parseFloat(value.toString())
|
||||
console.log('[CopyTradingEdit copyRatio formatter] 解析后:', num)
|
||||
if (isNaN(num)) {
|
||||
console.log('[CopyTradingEdit copyRatio formatter] NaN,返回空字符串')
|
||||
return ''
|
||||
}
|
||||
if (num > 10000) {
|
||||
console.log('[CopyTradingEdit copyRatio formatter] 超过最大值,返回 10000')
|
||||
return '10000'
|
||||
}
|
||||
const result = num.toString().replace(/\.0+$/, '')
|
||||
console.log('[CopyTradingEdit copyRatio formatter] 格式化后返回:', result)
|
||||
return result
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -276,6 +314,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -293,6 +337,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -320,6 +370,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -336,6 +392,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -364,6 +426,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -391,6 +459,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -405,6 +479,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -424,6 +504,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
@@ -435,6 +521,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
@@ -453,6 +545,12 @@ const CopyTradingEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ const CopyTradingList: React.FC = () => {
|
||||
render: (_: any, record: CopyTrading) => (
|
||||
<Tag color={record.copyMode === 'RATIO' ? 'blue' : 'green'}>
|
||||
{record.copyMode === 'RATIO'
|
||||
? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x`
|
||||
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
|
||||
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
|
||||
}
|
||||
</Tag>
|
||||
@@ -466,7 +466,7 @@ const CopyTradingList: React.FC = () => {
|
||||
color: '#666'
|
||||
}}>
|
||||
{record.copyMode === 'RATIO'
|
||||
? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x`
|
||||
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
|
||||
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,14 @@ import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } fr
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
interface BuyOrdersTabProps {
|
||||
copyTradingId: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -25,16 +28,16 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
side?: string
|
||||
status?: string
|
||||
}>({})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
if (copyTradingId && active) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
}, [copyTradingId, active, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
@@ -44,7 +47,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
@@ -57,17 +60,17 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '已完成' },
|
||||
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '未成交' },
|
||||
partially_matched: { color: 'warning', text: t('copyTradingOrders.statusPartiallySold') || '部分成交' },
|
||||
fully_matched: { color: 'success', text: t('copyTradingOrders.statusFullySold') || '全部成交' }
|
||||
}
|
||||
const config = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||
@@ -76,7 +79,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -90,7 +93,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -104,7 +107,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -184,7 +187,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
@@ -192,7 +195,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
@@ -203,7 +206,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
value={filters.marketId}
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
||||
allowClear
|
||||
@@ -213,10 +216,8 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
>
|
||||
<Option value="0">YES</Option>
|
||||
<Option value="1">NO</Option>
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
</Select>
|
||||
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
|
||||
allowClear
|
||||
@@ -224,14 +225,14 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||
>
|
||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '已完成'}</Option>
|
||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
|
||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
|
||||
</Select>
|
||||
|
||||
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
|
||||
|
||||
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
@@ -255,7 +256,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
})
|
||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
@@ -267,9 +268,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
@@ -280,9 +281,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
{getStatusTag(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyInfo') || '买入信息'}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
@@ -292,28 +293,28 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
|
||||
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchInfo') || '匹配信息'}</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
|
||||
|
||||
@@ -237,13 +237,51 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单)'}
|
||||
parser={(value) => {
|
||||
console.log('[EditModal copyRatio parser] 输入值:', value, '类型:', typeof value)
|
||||
// 移除 % 符号和其他非数字字符(保留小数点和负号)
|
||||
const cleaned = (value || '').toString().replace(/%/g, '').trim()
|
||||
console.log('[EditModal copyRatio parser] 清理后:', cleaned)
|
||||
const parsed = parseFloat(cleaned) || 0
|
||||
console.log('[EditModal copyRatio parser] 解析后:', parsed)
|
||||
if (parsed > 10000) {
|
||||
console.log('[EditModal copyRatio parser] 超过最大值,返回 10000')
|
||||
return 10000
|
||||
}
|
||||
if (parsed < 0.01) {
|
||||
console.log('[EditModal copyRatio parser] 小于最小值,返回 0.01')
|
||||
return 0.01
|
||||
}
|
||||
console.log('[EditModal copyRatio parser] 返回:', parsed)
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
console.log('[EditModal copyRatio formatter] 输入值:', value, '类型:', typeof value)
|
||||
if (!value && value !== 0) {
|
||||
console.log('[EditModal copyRatio formatter] 空值,返回空字符串')
|
||||
return ''
|
||||
}
|
||||
const num = parseFloat(value.toString())
|
||||
console.log('[EditModal copyRatio formatter] 解析后:', num)
|
||||
if (isNaN(num)) {
|
||||
console.log('[EditModal copyRatio formatter] NaN,返回空字符串')
|
||||
return ''
|
||||
}
|
||||
if (num > 10000) {
|
||||
console.log('[EditModal copyRatio formatter] 超过最大值,返回 10000')
|
||||
return '10000'
|
||||
}
|
||||
const result = num.toString().replace(/\.0+$/, '')
|
||||
console.log('[EditModal copyRatio formatter] 格式化后返回:', result)
|
||||
return result
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -276,6 +314,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -293,6 +337,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -320,6 +370,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -336,6 +392,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -364,6 +426,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -391,6 +459,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -405,6 +479,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -424,6 +504,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
@@ -435,6 +521,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
@@ -453,6 +545,12 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
interface MatchedOrdersTabProps {
|
||||
copyTradingId: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) => {
|
||||
const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, active = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -22,16 +25,16 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
sellOrderId?: string
|
||||
buyOrderId?: string
|
||||
}>({})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
if (copyTradingId && active) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
}, [copyTradingId, active, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
@@ -41,7 +44,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
@@ -54,13 +57,13 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
|
||||
@@ -69,7 +72,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -83,7 +86,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -123,8 +126,8 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
key: 'realizedPnl',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (value: string) => (
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
@@ -139,7 +142,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
@@ -147,7 +150,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
@@ -158,7 +161,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
value={filters.sellOrderId}
|
||||
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
|
||||
<Input
|
||||
placeholder={t('copyTradingOrders.filterBuyOrderId') || '筛选买入订单ID'}
|
||||
allowClear
|
||||
@@ -166,10 +169,10 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
value={filters.buyOrderId}
|
||||
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
|
||||
|
||||
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.search') || '查询'}</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
@@ -191,7 +194,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${order.sellOrderId}-${order.buyOrderId}-${order.matchedAt}`}
|
||||
@@ -204,8 +207,8 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: '8px'
|
||||
@@ -213,42 +216,42 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
|
||||
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchedQuantity') || '匹配数量'}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{formatUSDC(order.matchedQuantity)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.priceInfo') || '价格信息'}</div>
|
||||
<div style={{ fontSize: '13px', color: '#333' }}>
|
||||
{t('copyTradingOrders.buy') || '买入'}: {formatUSDC(order.buyPrice)} | {t('copyTradingOrders.sell') || '卖出'}: {formatUSDC(order.sellPrice)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '盈亏'}</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('copyTradingOrders.matchedAt') || '匹配时间'}: {formattedDate}
|
||||
|
||||
@@ -8,11 +8,14 @@ import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } f
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
interface SellOrdersTabProps {
|
||||
copyTradingId: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -24,16 +27,16 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
marketId?: string
|
||||
side?: string
|
||||
}>({})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (copyTradingId) {
|
||||
if (copyTradingId && active) {
|
||||
fetchOrders()
|
||||
}
|
||||
}, [copyTradingId, page, limit, filters])
|
||||
|
||||
}, [copyTradingId, active, page, limit, filters])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!copyTradingId) return
|
||||
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const request: OrderTrackingRequest = {
|
||||
@@ -43,7 +46,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
limit,
|
||||
...filters
|
||||
}
|
||||
|
||||
|
||||
const response = await apiService.orderTracking.list(request)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data as OrderTrackingListResponse
|
||||
@@ -56,13 +59,13 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getPnlColor = (value: string): string => {
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return '#666'
|
||||
return num >= 0 ? '#3f8600' : '#cf1322'
|
||||
}
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||
@@ -71,7 +74,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -85,7 +88,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -99,7 +102,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
@@ -153,8 +156,8 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
key: 'realizedPnl',
|
||||
width: isMobile ? 100 : 120,
|
||||
render: (value: string) => (
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
<span style={{
|
||||
color: getPnlColor(value),
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
@@ -169,7 +172,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
width: isMobile ? 120 : 160,
|
||||
render: (timestamp: number) => (
|
||||
<span style={{ fontSize: isMobile ? 11 : 12 }}>
|
||||
{isMobile
|
||||
{isMobile
|
||||
? new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
: new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
@@ -177,7 +180,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
@@ -188,7 +191,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
value={filters.marketId}
|
||||
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
|
||||
/>
|
||||
|
||||
|
||||
<Select
|
||||
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
|
||||
allowClear
|
||||
@@ -201,10 +204,10 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
<Option value="YES">YES</Option>
|
||||
<Option value="NO">NO</Option>
|
||||
</Select>
|
||||
|
||||
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
|
||||
|
||||
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{isMobile ? (
|
||||
<div>
|
||||
{loading ? (
|
||||
@@ -228,7 +231,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
})
|
||||
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
|
||||
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={order.orderId}
|
||||
@@ -240,9 +243,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
@@ -252,9 +255,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
<Tag>{displaySide}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellInfo') || '卖出信息'}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
@@ -264,32 +267,32 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
|
||||
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}</div>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
|
||||
|
||||
@@ -22,13 +22,13 @@ const CopyTradingOrdersModal: React.FC<CopyTradingOrdersModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(defaultTab)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab(defaultTab)
|
||||
}
|
||||
}, [open, defaultTab])
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('copyTradingOrders.title') || '订单列表'}
|
||||
@@ -38,25 +38,26 @@ const CopyTradingOrdersModal: React.FC<CopyTradingOrdersModalProps> = ({
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as TabType)}
|
||||
items={[
|
||||
{
|
||||
key: 'buy',
|
||||
label: t('copyTradingOrders.buyOrders') || '买入订单',
|
||||
children: <BuyOrdersTab copyTradingId={copyTradingId} />
|
||||
children: <BuyOrdersTab copyTradingId={copyTradingId} active={activeTab === 'buy'} />
|
||||
},
|
||||
{
|
||||
key: 'sell',
|
||||
label: t('copyTradingOrders.sellOrders') || '卖出订单',
|
||||
children: <SellOrdersTab copyTradingId={copyTradingId} />
|
||||
children: <SellOrdersTab copyTradingId={copyTradingId} active={activeTab === 'sell'} />
|
||||
},
|
||||
{
|
||||
key: 'matched',
|
||||
label: t('copyTradingOrders.matchedOrders') || '匹配关系',
|
||||
children: <MatchedOrdersTab copyTradingId={copyTradingId} />
|
||||
children: <MatchedOrdersTab copyTradingId={copyTradingId} active={activeTab === 'matched'} />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -127,23 +127,24 @@ const TemplateAdd: React.FC = () => {
|
||||
tooltip={t('templateAdd.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单'}
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('templateAdd.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单),默认 100%'}
|
||||
parser={(value) => {
|
||||
const parsed = parseFloat(value || '0')
|
||||
if (parsed > 1000) return 1000
|
||||
if (parsed > 10000) return 10000
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
if (!value) return ''
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (num > 1000) return '1000'
|
||||
return value.toString()
|
||||
if (isNaN(num)) return ''
|
||||
if (num > 10000) return '10000'
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -177,6 +178,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -194,6 +201,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -221,6 +234,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -251,6 +270,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.priceTolerancePlaceholder') || '默认 5%(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -265,6 +290,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -279,6 +310,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateAdd.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -298,6 +335,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('templateAdd.minPricePlaceholder') || '最低价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
@@ -309,6 +352,12 @@ const TemplateAdd: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('templateAdd.maxPricePlaceholder') || '最高价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
|
||||
@@ -162,23 +162,24 @@ const TemplateEdit: React.FC = () => {
|
||||
tooltip={t('templateEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单'}
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder={t('templateEdit.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单),默认 100%'}
|
||||
parser={(value) => {
|
||||
const parsed = parseFloat(value || '0')
|
||||
if (parsed > 1000) return 1000
|
||||
if (parsed > 10000) return 10000
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
if (!value) return ''
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (num > 1000) return '1000'
|
||||
return value.toString()
|
||||
if (isNaN(num)) return ''
|
||||
if (num > 10000) return '10000'
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -213,6 +214,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -230,6 +237,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -257,6 +270,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -287,6 +306,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -301,6 +326,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -315,6 +346,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('templateEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -334,6 +371,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('templateEdit.minPricePlaceholder') || '最低价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
@@ -345,6 +388,12 @@ const TemplateEdit: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder={t('templateEdit.maxPricePlaceholder') || '最高价(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
|
||||
@@ -471,23 +471,24 @@ const TemplateList: React.FC = () => {
|
||||
tooltip="跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单"
|
||||
>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
step={1}
|
||||
precision={0}
|
||||
min={0.01}
|
||||
max={10000}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
placeholder="例如:100 表示 100%(1:1 跟单),默认 100%"
|
||||
parser={(value) => {
|
||||
const parsed = parseFloat(value || '0')
|
||||
if (parsed > 1000) return 1000
|
||||
if (parsed > 10000) return 10000
|
||||
return parsed
|
||||
}}
|
||||
formatter={(value) => {
|
||||
if (!value) return ''
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (num > 1000) return '1000'
|
||||
return value.toString()
|
||||
if (isNaN(num)) return ''
|
||||
if (num > 10000) return '10000'
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -520,6 +521,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="固定金额,不随 Leader 订单大小变化,必须 >= 1"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -537,6 +544,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="仅在比例模式下生效(可选)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -564,6 +577,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="仅在比例模式下生效,必须 >= 1(可选)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -594,6 +613,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="默认 5%(可选)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -619,6 +644,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如:100(可选,不填写表示不启用)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -633,6 +664,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如:0.05(5美分,可选,不填写表示不启用)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -652,6 +689,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder="最低价(留空不限制)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
|
||||
@@ -663,6 +706,12 @@ const TemplateList: React.FC = () => {
|
||||
precision={4}
|
||||
style={{ width: '50%' }}
|
||||
placeholder="最高价(留空不限制)"
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Input.Group>
|
||||
|
||||
@@ -181,18 +181,25 @@ export const apiService = {
|
||||
*/
|
||||
login: (data: { username: string; password: string }) =>
|
||||
apiClient.post<ApiResponse<{ token: string }>>('/auth/login', data),
|
||||
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
resetPassword: (data: { resetKey: string; username: string; newPassword: string }) =>
|
||||
apiClient.post<ApiResponse<void>>('/auth/reset-password', data),
|
||||
|
||||
|
||||
/**
|
||||
* 检查是否首次使用
|
||||
*/
|
||||
checkFirstUse: () =>
|
||||
apiClient.post<ApiResponse<{ isFirstUse: boolean }>>('/auth/check-first-use', {})
|
||||
apiClient.post<ApiResponse<{ isFirstUse: boolean }>>('/auth/check-first-use', {}),
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 连接票据
|
||||
* 返回一个短期有效(30秒)的一次性票据
|
||||
*/
|
||||
getWebSocketTicket: () =>
|
||||
apiClient.post<ApiResponse<{ ticket: string }>>('/auth/ws-ticket', {})
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,30 +51,33 @@ class WebSocketManager {
|
||||
|
||||
/**
|
||||
* 连接 WebSocket(全局共享连接)
|
||||
* 使用短期票据认证,避免在 URL 中暴露 JWT
|
||||
*/
|
||||
connect(): void {
|
||||
async connect(): Promise<void> {
|
||||
// 检查是否有token,未登录不允许连接
|
||||
const token = this.getToken()
|
||||
if (!token) {
|
||||
console.log('[WebSocket] 未登录,不建立连接')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 如果已经连接或正在连接,直接返回
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 如果正在卸载,不允许连接
|
||||
if (this.isUnmounting) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
this.isConnecting = true
|
||||
const wsUrl = this.getWebSocketUrl()
|
||||
console.log('[WebSocket] 正在连接:', wsUrl)
|
||||
|
||||
|
||||
try {
|
||||
// 获取短期票据
|
||||
const wsUrl = await this.getWebSocketUrl()
|
||||
console.log('[WebSocket] 正在连接...')
|
||||
|
||||
// 如果已经有连接(但状态不是 OPEN),先关闭
|
||||
if (this.ws) {
|
||||
try {
|
||||
@@ -84,10 +87,10 @@ class WebSocketManager {
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
this.ws = ws
|
||||
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WebSocket] 连接成功')
|
||||
this.isConnecting = false
|
||||
@@ -95,17 +98,17 @@ class WebSocketManager {
|
||||
this.startPing()
|
||||
this.resubscribeAll() // 重新订阅所有频道
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data)
|
||||
}
|
||||
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] 连接错误:', error)
|
||||
this.isConnecting = false
|
||||
this.notifyConnectionStatus(false)
|
||||
}
|
||||
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WebSocket] 连接关闭')
|
||||
this.isConnecting = false
|
||||
@@ -324,14 +327,14 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket URL(带token认证)
|
||||
* 获取 WebSocket URL(使用短期票据认证)
|
||||
* 默认使用相对路径 /ws(通过反向代理转发)
|
||||
* 如果设置了 VITE_WS_URL 环境变量,则使用完整 URL(用于跨域场景)
|
||||
*/
|
||||
private getWebSocketUrl(): string {
|
||||
private async getWebSocketUrl(): Promise<string> {
|
||||
const envWsUrl = import.meta.env.VITE_WS_URL
|
||||
let wsBaseUrl: string
|
||||
|
||||
|
||||
if (envWsUrl) {
|
||||
// 如果设置了环境变量,使用完整 URL(支持跨域)
|
||||
wsBaseUrl = envWsUrl
|
||||
@@ -341,10 +344,22 @@ class WebSocketManager {
|
||||
const host = window.location.host
|
||||
wsBaseUrl = `${protocol}//${host}`
|
||||
}
|
||||
|
||||
|
||||
// 获取短期票据(避免在 URL 中暴露 JWT)
|
||||
// 使用动态导入避免循环依赖
|
||||
try {
|
||||
const { apiService } = await import('./api')
|
||||
const response = await apiService.auth.getWebSocketTicket()
|
||||
if (response.data.code === 0 && response.data.data?.ticket) {
|
||||
return `${wsBaseUrl}/ws?ticket=${encodeURIComponent(response.data.data.ticket)}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WebSocket] 获取票据失败,尝试使用 token 认证:', error)
|
||||
}
|
||||
|
||||
// 兼容旧方式:如果获取票据失败,回退到使用 token(不推荐)
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
// 通过查询参数传递token
|
||||
return `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
return `${wsBaseUrl}/ws`
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Account {
|
||||
proxyAddress: string // Polymarket 代理钱包地址
|
||||
accountName?: string
|
||||
isEnabled?: boolean // 是否启用
|
||||
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
apiKeyConfigured: boolean
|
||||
apiSecretConfigured: boolean
|
||||
apiPassphraseConfigured: boolean
|
||||
@@ -42,6 +43,7 @@ export interface AccountImportRequest {
|
||||
privateKey: string
|
||||
walletAddress: string
|
||||
accountName?: string
|
||||
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
/**
|
||||
* 格式化数字,自动去除尾随零
|
||||
* @param value - 数字值(字符串或数字)
|
||||
* @param maxDecimals - 最大小数位数(默认不限制)
|
||||
* @returns 格式化后的字符串,如果值为空或无效则返回 ''
|
||||
* @example
|
||||
* formatNumber(100.00) => "100"
|
||||
* formatNumber(100.50) => "100.5"
|
||||
* formatNumber(100.55) => "100.55"
|
||||
*/
|
||||
export const formatNumber = (value: string | number | undefined | null, maxDecimals?: number): string => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||
if (isNaN(num)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 如果有最大小数位数限制,先截断
|
||||
if (maxDecimals !== undefined) {
|
||||
const multiplier = Math.pow(10, maxDecimals)
|
||||
const truncated = Math.floor(num * multiplier) / multiplier
|
||||
return truncated.toFixed(maxDecimals).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
// 直接转换为字符串,然后去除尾随零
|
||||
return num.toString().replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 USDC 金额
|
||||
* 最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
|
||||
|
||||
Reference in New Issue
Block a user