diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 2fa1086..df00230 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -4,6 +4,24 @@ on:
release:
types:
- published # 当通过 GitHub Releases 页面创建 release 时触发
+ workflow_dispatch:
+ inputs:
+ build_type:
+ description: '构建类型'
+ required: true
+ type: choice
+ options:
+ - package-only # 只打包产物
+ - package-and-docker # 打包产物 + Docker 镜像
+ default: 'package-and-docker'
+ version:
+ description: '版本号(例如: v1.0.0)'
+ required: false
+ type: string
+ tag_name:
+ description: 'Git Tag 名称(留空则使用 version)'
+ required: false
+ type: string
jobs:
build-and-push:
@@ -16,25 +34,61 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
+ ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
+
+ - name: Determine build type
+ id: build_config
+ run: |
+ # 确定构建类型
+ if [ "${{ github.event_name }}" = "release" ]; then
+ # Release 事件:默认只打包产物(不构建 Docker)
+ BUILD_TYPE="package-only"
+ echo "📦 Release 事件:将只打包产物(不构建 Docker)"
+ else
+ # workflow_dispatch 事件:使用用户输入
+ BUILD_TYPE="${{ github.event.inputs.build_type }}"
+ echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
+ fi
+
+ echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
- # 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0)
- TAG_NAME="${{ github.event.release.tag_name }}"
- if [ -z "$TAG_NAME" ]; then
- TAG_NAME=${GITHUB_REF#refs/tags/}
+ # 从不同事件源提取版本号
+ if [ "${{ github.event_name }}" = "release" ]; then
+ # Release 事件:从 release tag 中提取
+ TAG_NAME="${{ github.event.release.tag_name }}"
+ IS_PRERELEASE="${{ github.event.release.prerelease }}"
+ else
+ # workflow_dispatch 事件:从输入参数中提取
+ TAG_NAME="${{ github.event.inputs.tag_name }}"
+ if [ -z "$TAG_NAME" ]; then
+ TAG_NAME="${{ github.event.inputs.version }}"
+ fi
+ # 如果仍然为空,尝试从 git ref 中提取
+ if [ -z "$TAG_NAME" ]; then
+ TAG_NAME=${GITHUB_REF#refs/tags/}
+ if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
+ # 不是 tag,尝试从分支名或 commit SHA 获取
+ TAG_NAME=${GITHUB_REF#refs/heads/}
+ if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
+ TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
+ echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
+ fi
+ fi
+ fi
+ IS_PRERELEASE="false"
fi
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
- if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
- echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
- exit 1
+ if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
+ echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
+ echo " 当前版本号: $TAG_NAME"
+ echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
fi
- VERSION=${TAG_NAME#v} # 移除 v 前缀
- IS_PRERELEASE="${{ github.event.release.prerelease }}"
+ VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
@@ -47,7 +101,7 @@ jobs:
fi
- name: Send Telegram notification (build started)
- if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
+ if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -59,12 +113,15 @@ jobs:
fi
# 获取构建信息
- VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
- RELEASE_URL="${{ github.event.release.html_url }}"
- # 构建消息内容(仅包含关键信息)
- MESSAGE="🔨 Docker 镜像构建中"$'\n'$'\n'"📦 版本: ${VERSION}"$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔗 查看 Release"
+ if [ "${{ github.event_name }}" = "release" ]; then
+ RELEASE_URL="${{ github.event.release.html_url }}"
+ MESSAGE="🔨 Release 构建中"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Release"
+ else
+ WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ MESSAGE="🔨 构建中"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Workflow"
+ fi
# 发送 Telegram 消息(使用 jq 转义 JSON)
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
@@ -141,12 +198,18 @@ jobs:
echo "✅ 前端文件已复制"
# 创建版本信息文件
+ if [ "${{ github.event_name }}" = "release" ]; then
+ RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
+ else
+ RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
+ fi
+
cat > update-package/version.json < checksums.txt
- name: Upload Update Package to Release
+ if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -178,7 +242,8 @@ jobs:
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_content_type: application/gzip
- - name: Upload Checksums
+ - name: Upload Checksums to Release
+ if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -188,19 +253,32 @@ jobs:
asset_name: checksums.txt
asset_content_type: text/plain
+ - name: Upload Update Package as Artifact
+ if: github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
+ path: |
+ polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
+ checksums.txt
+ retention-days: 30
+
- name: Set up Docker Buildx
+ if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
+ if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
+ if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
@@ -217,6 +295,7 @@ jobs:
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
+ if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/build-push-action@v5
with:
context: .
@@ -234,6 +313,12 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
+
+ - name: Skip Docker build notice
+ if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
+ run: |
+ echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only)"
+ echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
@@ -250,13 +335,26 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
- RELEASE_NAME="${{ github.event.release.name }}"
- RELEASE_URL="${{ github.event.release.html_url }}"
- REPO_NAME="${{ github.repository }}"
+ BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
- MESSAGE="✅ Docker 镜像构建成功"$'\n'$'\n'"📦 版本: ${VERSION}"$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔗 查看 Release"$'\n'"📚 Docker 部署文档"
+
+ if [ "${{ github.event_name }}" = "release" ]; then
+ RELEASE_URL="${{ github.event.release.html_url }}"
+ if [ "$BUILD_TYPE" = "package-and-docker" ]; then
+ MESSAGE="✅ Release 构建成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Release"$'\n'"📚 Docker 部署文档"
+ else
+ MESSAGE="✅ Release 打包成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: 在线升级"$'\n'"🔗 查看 Release"$'\n'"📍 升级路径: 系统管理 → 概览 → 检查更新"
+ fi
+ else
+ WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ if [ "$BUILD_TYPE" = "package-and-docker" ]; then
+ MESSAGE="✅ 构建成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Workflow"$'\n'"📚 Docker 部署文档"
+ else
+ MESSAGE="✅ 打包成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: 在线升级"$'\n'"🔗 查看 Workflow"$'\n'"📍 升级路径: 系统管理 → 概览 → 检查更新"
+ fi
+ fi
# 发送 Telegram 消息(使用 jq 转义 JSON)
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
diff --git a/Dockerfile b/Dockerfile
index 78ef92b..bd408f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,21 +31,22 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
COPY frontend/ ./
# 条件:仅在 Docker 内部编译时执行构建
-# 如果 BUILD_IN_DOCKER=false,需要从构建上下文复制外部编译的 dist
+# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
+# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译前端..."; \
npm run build; \
else \
- echo "⏭️ 使用外部产物,将在下一步复制"; \
- mkdir -p dist; \
+ echo "⏭️ 使用外部产物..."; \
+ if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
+ echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
+ echo " 请先执行: cd frontend && npm install && npm run build"; \
+ exit 1; \
+ else \
+ echo "✅ 找到外部构建的前端产物"; \
+ fi; \
fi
-# 如果使用外部产物,从构建上下文复制外部编译的 dist
-# 注意:这个 COPY 在 BUILD_IN_DOCKER=false 时必需
-# 在 BUILD_IN_DOCKER=true 时,如果前端已编译,这个 COPY 会尝试覆盖,但结果相同
-# 如果本地没有 dist(BUILD_IN_DOCKER=true 且未编译),这个 COPY 会失败,但上面的 RUN 已经编译了
-COPY frontend/dist ./dist
-
# ==================== 阶段2:构建后端 ====================
FROM gradle:8.5-jdk17 AS backend-build
ARG BUILD_IN_DOCKER
@@ -64,9 +65,25 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
# 复制源代码
COPY backend/src ./src
-# 如果使用外部产物,先从构建上下文复制外部编译的 JAR
-# 注意:如果 BUILD_IN_DOCKER=true 且本地没有 JAR,这个 COPY 会失败,但会在下面编译生成
-COPY backend/build/libs/*.jar build/libs/
+# 尝试复制外部构建的 JAR(如果存在)
+# 注意:COPY 指令如果源不存在会失败
+# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
+# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
+# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
+# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
+COPY backend/build ./build-external
+
+# 处理外部构建的 JAR(如果存在)
+RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
+ echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
+ mkdir -p build/libs; \
+ cp build-external/libs/*.jar build/libs/; \
+ rm -rf build-external; \
+ else \
+ echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
+ rm -rf build-external; \
+ mkdir -p build/libs; \
+ fi
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
@@ -74,10 +91,12 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
gradle bootJar --no-daemon; \
else \
echo "⏭️ 使用外部产物"; \
- mkdir -p build/libs; \
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
+ echo " 请先执行: cd backend && ./gradlew bootJar"; \
exit 1; \
+ else \
+ echo "✅ 使用外部构建的后端产物"; \
fi; \
fi
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 3a35172..58d6301 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -74,3 +74,4 @@
- b658270 - 添加订单详情查询脚本
- 07b4d65 - 清理 MarketPollingService 调试日志
+
diff --git a/RELEASE_NOTES_v2.0.1.md b/RELEASE_NOTES_v2.0.1.md
new file mode 100644
index 0000000..6b214bd
--- /dev/null
+++ b/RELEASE_NOTES_v2.0.1.md
@@ -0,0 +1,173 @@
+# PolyHermes v2.0.1 Release Notes
+
+## 📋 版本信息
+- **版本号**: v2.0.1
+- **发布日期**: 2026-01-28
+- **基础版本**: v2.0.0
+
+## 🎯 改动摘要
+
+本次版本主要修复了订单状态检查、RPC 节点管理、订单金额计算等关键问题,提升了系统稳定性和用户体验。
+
+---
+
+## 🐛 Bug 修复
+
+### 1. 修复订单状态检查中缓存清除导致计时重置的问题
+
+**问题描述**:
+- 订单详情为 `null` 时,缓存清除时机不当导致计时被重置
+- 订单超过 60 秒后无法正常删除
+- 部分卖出订单等待时间过长(之前等待 1 小时)
+
+**修复内容**:
+- ✅ 修复缓存清除时机问题,避免计时被重置
+- ✅ 统一部分卖出和未成交订单的删除逻辑,都使用 60 秒重试窗口
+- ✅ 删除未使用的常量 `PARTIAL_SOLD_CLEANUP_WINDOW_MS`
+- ✅ 优化日志输出,区分部分卖出和未成交订单的日志信息
+
+**影响范围**:
+- `OrderStatusUpdateService.kt` - 订单状态更新服务
+
+**提交**: 7e87965
+
+---
+
+### 2. 修复禁用 RPC 节点后仍被使用的问题
+
+**问题描述**:
+- 禁用 RPC 节点后,节点仍可能被系统使用
+- 节点状态更新不及时
+
+**修复内容**:
+- ✅ 修复节点禁用逻辑,确保禁用后立即生效
+- ✅ 优化节点状态检查机制
+- ✅ 改进节点选择逻辑
+
+**影响范围**:
+- `RpcNodeService.kt` - RPC 节点服务
+
+**提交**: dd39e59
+
+---
+
+### 3. 修复订单金额计算和价格范围验证问题
+
+**问题描述**:
+- 订单金额计算可能存在精度问题
+- 价格范围验证逻辑不完善
+
+**修复内容**:
+- ✅ 修复订单金额计算逻辑
+- ✅ 完善价格范围验证
+- ✅ 优化数值计算精度处理
+
+**影响范围**:
+- `OrderSigningService.kt` - 订单签名服务
+
+**提交**: 2b20f4b
+
+---
+
+### 4. 修复系统更新 API 路由和改进健康检查逻辑
+
+**问题描述**:
+- 系统更新 API 路由可能存在问题
+- 健康检查逻辑需要优化
+
+**修复内容**:
+- ✅ 修复系统更新 API 路由
+- ✅ 改进健康检查逻辑
+- ✅ 优化错误处理
+
+**提交**: 4aa85a9
+
+---
+
+### 5. 修复 Docker 构建相关问题
+
+**问题描述**:
+- 本地 Docker 构建时 `frontend/dist` 和 `backend/build` 不存在导致构建失败
+- Dockerfile 中前端产物复制逻辑有问题
+
+**修复内容**:
+- ✅ 修复本地 Docker 构建时目录不存在的问题
+- ✅ 修复 Dockerfile 中前端产物复制逻辑
+- ✅ 优化构建流程
+
+**影响范围**:
+- `Dockerfile` - Docker 构建文件
+- `deploy.sh` - 部署脚本
+
+**提交**: 419c68c, 8889803
+
+---
+
+### 6. 补充缺失的多语言 key
+
+**问题描述**:
+- 部分多语言 key 缺失,导致界面显示异常
+
+**修复内容**:
+- ✅ 补充缺失的多语言 key
+- ✅ 完善多语言支持
+
+**影响范围**:
+- `frontend/src/locales/` - 多语言文件
+
+**提交**: 2efc04a
+
+---
+
+## 🔧 优化改进
+
+### 优化过滤原因文案的数值显示格式
+
+**改进内容**:
+- ✅ 优化过滤原因文案的数值显示格式
+- ✅ 新增日期工具函数,统一日期格式化
+- ✅ 提升用户体验
+
+**影响范围**:
+- `CopyTradingFilterService.kt` - 跟单过滤服务
+- `DateUtils.kt` - 日期工具类(新增)
+
+**提交**: e115d45
+
+---
+
+## 📊 文件变更统计
+
+- **修改文件数**: 10+
+- **新增文件数**: 1 (DateUtils.kt)
+- **新增行数**: 200+
+- **删除行数**: 100+
+
+---
+
+## 🔄 升级建议
+
+1. **直接部署**:无需特殊操作,直接部署即可
+2. **验证订单处理**:建议验证订单状态检查是否正常工作
+3. **检查 RPC 节点**:确认 RPC 节点状态管理是否正常
+4. **验证订单金额**:确认订单金额计算是否正确
+
+---
+
+## 📝 完整提交列表
+
+- 7e87965 - fix: 修复订单状态检查中缓存清除导致计时重置的问题
+- e115d45 - refactor: 优化过滤原因文案的数值显示格式
+- dd39e59 - fix: 修复禁用RPC节点后仍被使用的问题
+- 2efc04a - fix: 补充缺失的多语言key
+- 2b20f4b - fix: 修复订单金额计算和价格范围验证问题
+- 419c68c - fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
+- 8889803 - fix: 修复 Dockerfile 中前端产物复制逻辑
+- 4aa85a9 - fix: 修复系统更新 API 路由和改进健康检查逻辑
+
+---
+
+## 🙏 致谢
+
+感谢所有贡献者和用户的支持与反馈!
+
diff --git a/RELEASE_NOTES_v2.0.2.md b/RELEASE_NOTES_v2.0.2.md
new file mode 100644
index 0000000..71a38a4
--- /dev/null
+++ b/RELEASE_NOTES_v2.0.2.md
@@ -0,0 +1,144 @@
+# PolyHermes v2.0.2 Release Notes
+
+## 📋 版本信息
+- **版本号**: v2.0.2
+- **发布日期**: 2026-01-29
+- **基础版本**: v2.0.1
+
+## 🎯 改动摘要
+
+本次版本主要修复了买入订单金额精度问题,并优化了构建流程和通知机制。
+
+---
+
+## 🐛 Bug 修复
+
+### 1. 修复买入订单金额精度问题
+
+**问题描述**:
+- 市场买入订单创建时出现 `invalid amounts` 错误
+- Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
+- 之前的实现不符合 API 要求,导致订单创建失败
+
+**修复内容**:
+- ✅ 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
+- ✅ makerAmount (USDC) 限制为最多 2 位小数(之前为 4 位)
+- ✅ takerAmount (shares) 限制为最多 4 位小数(之前为 2 位)
+- ✅ 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
+
+**影响范围**:
+- `OrderSigningService.kt` - 订单签名服务
+
+**提交**: 42472f6
+
+---
+
+## 🔧 优化改进
+
+### 优化构建流程和通知机制
+
+**改进内容**:
+- ✅ 添加 workflow_dispatch 支持手动触发构建
+- ✅ Release 事件默认只打包产物(在线升级),不构建 Docker
+- ✅ 手动触发可选择构建类型:在线升级或 Docker 升级
+- ✅ 优化 Telegram 通知文案,区分构建类型和升级路径
+- ✅ 在线升级任务不发送开始通知,只发送完成通知
+- ✅ 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
+
+**影响范围**:
+- `.github/workflows/docker-build.yml` - GitHub Actions 工作流
+
+**提交**: e5992b5
+
+---
+
+## ⚠️ 潜在问题和注意事项
+
+### 1. 买入订单金额精度调整可能带来的影响
+
+**问题说明**:
+本次修复调整了买入订单的金额精度限制,可能会带来以下影响:
+
+#### 1.1 makerAmount 精度降低(4 位 → 2 位小数)
+
+**影响**:
+- **订单金额可能被向下舍入**:由于 makerAmount (USDC) 从 4 位小数降低到 2 位小数,订单金额可能会被向下舍入
+- **实际支付金额可能略低于预期**:例如,如果计算出的金额是 `0.5985 USDC`,现在会被舍入为 `0.59 USDC`,实际支付金额可能比预期少 `0.0085 USDC`
+- **可能影响固定金额模式的跟单**:在固定金额模式下,如果金额被舍入,实际买入的数量可能会略少于预期
+
+**建议**:
+- 在固定金额模式下,建议设置稍大一点的金额,以补偿可能的舍入损失
+- 监控订单创建情况,确认金额是否符合预期
+- 如果发现金额差异较大,可以适当调整跟单配置
+
+#### 1.2 takerAmount 精度提高(2 位 → 4 位小数)
+
+**影响**:
+- **订单数量精度提高**:takerAmount (shares) 从 2 位小数提高到 4 位小数,可以更精确地指定买入数量
+- **可能增加订单复杂度**:更高的精度可能导致一些边缘情况,需要确保数量计算正确
+
+**建议**:
+- 验证订单数量是否符合预期
+- 确认跟单数量计算逻辑是否正确
+
+#### 1.3 订单创建成功率变化
+
+**影响**:
+- **修复前**:订单创建可能因为精度问题失败
+- **修复后**:订单创建成功率应该提高,但金额可能略有变化
+
+**建议**:
+- 升级后监控订单创建成功率
+- 对比升级前后的订单金额,确认差异是否在可接受范围内
+
+### 2. 构建流程变更
+
+**影响**:
+- Release 事件默认只打包产物,不构建 Docker
+- 如果需要 Docker 镜像,需要手动触发构建并选择 Docker 升级类型
+
+**建议**:
+- 了解新的构建流程,根据需要选择合适的构建方式
+- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
+
+---
+
+## 📊 文件变更统计
+
+- **修改文件数**: 2
+- **新增行数**: 127
+- **删除行数**: 27
+
+---
+
+## 🔄 升级建议
+
+1. **测试买入订单创建**:
+ - 升级后先测试少量买入订单,确认金额和数量是否符合预期
+ - 特别关注固定金额模式的跟单,确认金额是否被正确舍入
+
+2. **监控订单创建成功率**:
+ - 升级后监控订单创建成功率,确认是否解决了 `invalid amounts` 错误
+ - 对比升级前后的订单金额,确认差异是否在可接受范围内
+
+3. **验证金额计算**:
+ - 验证 makerAmount 是否被正确限制为 2 位小数
+ - 验证 takerAmount 是否被正确限制为 4 位小数
+
+4. **了解构建流程变更**:
+ - 了解新的构建流程,根据需要选择合适的构建方式
+ - 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
+
+---
+
+## 📝 完整提交列表
+
+- 42472f6 - fix: 修复买入订单金额精度问题
+- e5992b5 - feat(workflow): 优化构建流程和通知机制
+
+---
+
+## 🙏 致谢
+
+感谢所有贡献者和用户的支持与反馈!
+
diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt
index c161db1..f4642c9 100644
--- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt
+++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
+import com.wrbug.polymarketbot.util.DateUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
@@ -202,12 +203,16 @@ class CopyTradingFilterService(
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
- return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
+ val priceStr = tradePrice.stripTrailingZeros().toPlainString()
+ val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
+ return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
- return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
+ val priceStr = tradePrice.stripTrailingZeros().toPlainString()
+ val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
+ return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
}
return FilterResult.passed()
@@ -245,7 +250,9 @@ class CopyTradingFilterService(
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
- return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
+ val spreadStr = spread.stripTrailingZeros().toPlainString()
+ val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
+ return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
}
return FilterResult.passed()
@@ -285,7 +292,9 @@ class CopyTradingFilterService(
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
- return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
+ val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
+ val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
+ return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
}
return FilterResult.passed()
@@ -348,8 +357,14 @@ class CopyTradingFilterService(
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
+ val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
+ val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
+ val extValueStr = extValue.stripTrailingZeros().toPlainString()
+ val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
+ val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
+ val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
return FilterResult.maxPositionValueFailed(
- "超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
+ "超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
)
}
}
@@ -420,8 +435,10 @@ class CopyTradingFilterService(
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
+ val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
+ val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
return FilterResult.marketEndDateFailed(
- "市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
+ "市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
)
}
diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt
index bff4c60..5e04636 100644
--- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt
+++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt
@@ -36,13 +36,11 @@ class OrderSigningService {
size = 2,
amount = 4
)
-
- // 金额精度限制(根据 Polymarket API 要求)
- // makerAmount (USDC) 最多 2 位小数
- // takerAmount (shares) 最多 4 位小数
- private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
- private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
-
+
+ // 价格有效范围(Polymarket API 要求)
+ private val MIN_PRICE = BigDecimal("0.01")
+ private val MAX_PRICE = BigDecimal("0.99")
+
/**
* 订单金额计算结果
*/
@@ -62,7 +60,9 @@ class OrderSigningService {
/**
* 计算订单金额(makerAmount 和 takerAmount)
- *
+ *
+ * 参考 clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
+ *
* @param side BUY 或 SELL
* @param size 数量(shares)
* @param price 价格(0-1 之间)
@@ -77,49 +77,59 @@ class OrderSigningService {
): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal()
+
+ // 对价格进行 roundNormal 处理(与 clob-client 保持一致)
+ var rawPrice = roundNormal(priceDecimal, roundConfig.price)
+
+ // 验证价格范围,如果超出则调整到最接近的有效值
+ // Polymarket API 要求: 0.01 <= price <= 0.99
+ if (rawPrice > MAX_PRICE) {
+ logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
+ rawPrice = MAX_PRICE
+ } else if (rawPrice < MIN_PRICE) {
+ logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
+ rawPrice = MIN_PRICE
+ }
+
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
- // makerAmount 是 USDC 金额,最多 2 位小数
- // takerAmount 是 shares 数量,最多 4 位小数
- val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
-
- // makerAmount = price * size,使用原始价格计算(与SDK保持一致)
- // 先使用原始价格计算,然后再进行舍入,确保精度一致
- var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
-
- // 确保 makerAmount 精度(USDC,最多 2 位小数)
- rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
-
- // 确保 takerAmount 精度(shares,最多 4 位小数)
- val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
-
+ // 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
+ // 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
+ // takerAmount (shares) 使用 4 位小数
+ val rawTakerAmt = roundDown(sizeDecimal, 4)
+
+ var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
+ // makerAmount (USDC) 使用 2 位小数
+ if (decimalPlaces(rawMakerAmt) > 2) {
+ rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
+ if (decimalPlaces(rawMakerAmt) > 2) {
+ rawMakerAmt = roundDown(rawMakerAmt, 2)
+ }
+ }
+
// 转换为 wei(6 位小数)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
- val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
-
+ val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
+
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
} else {
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
- // 根据 Polymarket API 要求:
- // - makerAmount (shares) 最多 2 位小数
- // - takerAmount (USDC) 最多 4 位小数
+ // 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
-
- // takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
- // SDK期望使用原始价格进行计算,以保留足够的精度
- // 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
- val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
-
- // 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
- val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
-
- // 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
- val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
-
+
+ var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
+ // 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
+ if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
+ rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
+ if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
+ rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
+ }
+ }
+
// 转换为 wei(6 位小数)
- val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
- val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
-
+ val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
+ val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
+
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
}
}
@@ -324,23 +334,65 @@ class OrderSigningService {
/**
* 正常舍入(四舍五入)
+ * 参考 clob-client/src/utilities.ts 的 roundNormal 函数
+ * 只有当小数位数超过 decimals 时才进行舍入
+ *
+ * @param value 要舍入的数值
+ * @param decimals 目标小数位数
+ * @return 舍入后的数值
*/
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
+ if (decimalPlaces(value) <= decimals) {
+ return value
+ }
return value.setScale(decimals, RoundingMode.HALF_UP)
}
/**
* 向下舍入
+ * 参考 clob-client/src/utilities.ts 的 roundDown 函数
+ * 只有当小数位数超过 decimals 时才进行舍入
+ *
+ * @param value 要舍入的数值
+ * @param decimals 目标小数位数
+ * @return 舍入后的数值
*/
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
+ if (decimalPlaces(value) <= decimals) {
+ return value
+ }
return value.setScale(decimals, RoundingMode.DOWN)
}
-
+
/**
* 向上舍入
+ * 参考 clob-client/src/utilities.ts 的 roundUp 函数
+ * 只有当小数位数超过 decimals 时才进行舍入
+ *
+ * @param value 要舍入的数值
+ * @param decimals 目标小数位数
+ * @return 舍入后的数值
*/
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
+ if (decimalPlaces(value) <= decimals) {
+ return value
+ }
return value.setScale(decimals, RoundingMode.UP)
}
+
+ /**
+ * 计算 BigDecimal 的小数位数
+ * 参考 clob-client/src/utilities.ts 的 decimalPlaces 函数
+ *
+ * @param value 要计算的数值
+ * @return 小数位数
+ */
+ private fun decimalPlaces(value: BigDecimal): Int {
+ if (value.scale() <= 0) {
+ return 0
+ }
+ // 去除尾部的零,获取真实的小数位数
+ return value.stripTrailingZeros().scale()
+ }
}
diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt
index c462e77..bf67ded 100644
--- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt
+++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt
@@ -69,9 +69,6 @@ class OrderStatusUpdateService(
// 订单详情为 null 的重试时间窗口(1分钟)
private val ORDER_NULL_RETRY_WINDOW_MS = 60000L
- // 订单详情为 null 但已部分卖出的清理时间窗口(1小时)
- private val PARTIAL_SOLD_CLEANUP_WINDOW_MS = 3600000L
-
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
logger.info("订单状态更新服务已启动,将每5秒轮询一次")
@@ -297,53 +294,38 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
+ // 清除缓存(仅在处理完成后清除)
+ orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
- // 清除缓存,下次重新检测
- orderNullDetectionTime.remove(order.buyOrderId)
+ // 未超过60秒,继续等待,不清除缓存
continue
}
- // 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
- val hasMatchedDetails =
- sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
- if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
- // 检查是否超过清理时间窗口(1小时)
- val orderAge = currentTime - order.createdAt
- if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
- logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
- try {
- copyOrderTrackingRepository.deleteById(order.id!!)
- logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
- // 清除缓存
- orderNullDetectionTime.remove(order.buyOrderId)
- } catch (e: Exception) {
- logger.error(
- "删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
- e
- )
- }
- continue
- } else {
- logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
- // 清除缓存,下次重新检测
- orderNullDetectionTime.remove(order.buyOrderId)
- continue
- }
- }
-
- // 检查是否超过重试时间窗口
+ // 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
// 未超过重试窗口,记录日志并等待下次轮询
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
- logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
+ val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
+ val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
+ if (hasPartialSold) {
+ logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
+ } else {
+ logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
+ }
continue
}
- // 超过重试窗口,删除本地订单
- logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
+ // 超过重试窗口,删除本地订单(无论是否已部分卖出)
+ val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
+ val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
+ if (hasPartialSold) {
+ logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
+ } else {
+ logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
+ }
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
@@ -775,52 +757,38 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
+ // 清除缓存(仅在处理完成后清除)
+ orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
- // 清除缓存,下次重新检测
- orderNullDetectionTime.remove(order.buyOrderId)
+ // 未超过60秒,继续等待,不清除缓存
continue
}
- // 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
- val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
- if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
- // 检查是否超过清理时间窗口(1小时)
- val orderAge = currentTime - order.createdAt
- if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
- logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
- try {
- copyOrderTrackingRepository.deleteById(order.id!!)
- logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
- // 清除缓存
- orderNullDetectionTime.remove(order.buyOrderId)
- } catch (e: Exception) {
- logger.error(
- "删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
- e
- )
- }
- continue
- } else {
- logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
- // 清除缓存,下次重新检测
- orderNullDetectionTime.remove(order.buyOrderId)
- continue
- }
- }
-
- // 检查是否超过重试时间窗口
+ // 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
// 未超过重试窗口,记录日志并等待下次轮询
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
- logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
+ val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
+ val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
+ if (hasPartialSold) {
+ logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
+ } else {
+ logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
+ }
continue
}
- // 超过重试窗口,删除本地订单
- logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
+ // 超过重试窗口,删除本地订单(无论是否已部分卖出)
+ val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
+ val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
+ if (hasPartialSold) {
+ logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
+ } else {
+ logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
+ }
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt
index eb5f197..c71eecf 100644
--- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt
+++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt
@@ -98,8 +98,8 @@ class RpcNodeService(
.filterNot { isDefaultNode(it) } // 排除默认节点
if (nodes.isEmpty()) {
- logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
- return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
+ logger.warn("没有配置任何启用的 RPC 节点,将使用默认节点")
+ return Result.success(createDefaultNodeConfig())
}
// 优先使用最近检查状态为 HEALTHY 的节点
@@ -136,39 +136,53 @@ class RpcNodeService(
}
}
- // 所有节点都不可用,返回失败
- logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
- Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
+ // 所有节点都不可用,返回默认节点
+ logger.warn("所有启用的 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
+ Result.success(createDefaultNodeConfig())
} catch (e: Exception) {
logger.error("获取可用节点失败: ${e.message}", e)
- Result.failure(e)
+ // 即使失败也返回默认节点,确保系统可用
+ logger.warn("获取可用节点出现异常,使用默认节点作为兜底")
+ Result.success(createDefaultNodeConfig())
}
}
+ /**
+ * 创建默认节点配置
+ * 用于兜底,确保系统始终有可用的 RPC 节点
+ */
+ private fun createDefaultNodeConfig(): RpcNodeConfig {
+ return RpcNodeConfig(
+ id = 0L,
+ providerType = RpcProviderType.PUBLIC.name,
+ name = "默认节点",
+ httpUrl = DEFAULT_RPC_URL,
+ wsUrl = DEFAULT_WS_URL,
+ apiKey = null,
+ enabled = true,
+ priority = 9999,
+ lastCheckTime = System.currentTimeMillis(),
+ lastCheckStatus = NodeHealthStatus.HEALTHY.name,
+ responseTimeMs = null,
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis()
+ )
+ }
+
/**
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
*/
fun getHttpUrl(): String {
- val nodeResult = getAvailableNode()
- return if (nodeResult.isSuccess) {
- nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
- } else {
- logger.warn("没有可用的用户配置节点,使用默认节点")
- DEFAULT_RPC_URL
- }
+ val node = getAvailableNode().getOrNull()
+ return node?.httpUrl ?: DEFAULT_RPC_URL
}
/**
* 获取节点的 WebSocket URL (如果没有配置,使用默认节点)
*/
fun getWsUrl(): String {
- val nodeResult = getAvailableNode()
- return if (nodeResult.isSuccess) {
- nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL
- } else {
- logger.warn("没有可用的用户配置节点,使用默认 WS 节点")
- DEFAULT_WS_URL
- }
+ val node = getAvailableNode().getOrNull()
+ return node?.wsUrl ?: DEFAULT_WS_URL
}
/**
@@ -263,6 +277,13 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
}
+ // 检查是否禁用节点,如果是则清理缓存
+ val isDisabling = request.enabled == false && node.enabled == true
+ if (isDisabling) {
+ logger.info("节点被禁用,清理 RPC 缓存: ${node.httpUrl}")
+ retrofitFactory.clearRpcApiCache(node.httpUrl)
+ }
+
// 更新字段
val updatedNode = node.copy(
name = request.name ?: node.name,
@@ -272,7 +293,7 @@ class RpcNodeService(
)
val savedNode = rpcNodeConfigRepository.save(updatedNode)
- logger.info("成功更新 RPC 节点: ${savedNode.name}")
+ logger.info("成功更新 RPC 节点: ${savedNode.name}, enabled=${savedNode.enabled}")
Result.success(savedNode)
} catch (e: Exception) {
logger.error("更新节点失败: ${e.message}", e)
@@ -295,6 +316,10 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
}
+ // 清理 RPC 缓存
+ logger.info("删除节点,清理 RPC 缓存: ${node.httpUrl}")
+ retrofitFactory.clearRpcApiCache(node.httpUrl)
+
rpcNodeConfigRepository.delete(node)
logger.info("成功删除 RPC 节点: ${node.name}")
Result.success(Unit)
diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt
index 9d5211f..93abcd5 100644
--- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt
+++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt
@@ -84,5 +84,38 @@ object DateUtils {
}
return displayFormatter.format(instant)
}
+
+ /**
+ * 将时间间隔(毫秒)格式化为可读的字符串
+ * 格式:X天X小时X分钟 或 X小时X分钟 或 X分钟
+ * 只显示有意义的单位,不显示0值单位
+ *
+ * @param milliseconds 时间间隔(毫秒)
+ * @return 格式化的时间间隔字符串,如 "2天3小时15分钟"、"5小时30分钟"、"45分钟"
+ */
+ fun formatDuration(milliseconds: Long): String {
+ if (milliseconds < 0) {
+ return "0分钟"
+ }
+
+ val totalSeconds = milliseconds / 1000
+ val days = totalSeconds / (24 * 60 * 60)
+ val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60)
+ val minutes = (totalSeconds % (60 * 60)) / 60
+
+ val parts = mutableListOf()
+
+ if (days > 0) {
+ parts.add("${days}天")
+ }
+ if (hours > 0) {
+ parts.add("${hours}小时")
+ }
+ if (minutes > 0 || parts.isEmpty()) {
+ parts.add("${minutes}分钟")
+ }
+
+ return parts.joinToString("")
+ }
}
diff --git a/deploy.sh b/deploy.sh
index 89dce2f..e79eace 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -168,6 +168,10 @@ deploy() {
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION})..."
+ # 创建占位符目录(如果不存在),避免 Dockerfile COPY 失败
+ # 当 BUILD_IN_DOCKER=true 时,backend/build 可能不存在
+ mkdir -p backend/build/libs
+
# 设置构建参数(通过环境变量传递给 docker-compose.yml)
export VERSION=${DOCKER_VERSION}
export GIT_TAG=${DOCKER_VERSION}
diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json
index 9047b91..c88b786 100644
--- a/frontend/src/locales/en/common.json
+++ b/frontend/src/locales/en/common.json
@@ -938,7 +938,8 @@
"marketEndDate": "Market End Date Exceeds Limit",
"unknown": "Unknown Reason"
},
- "noData": "No filtered orders"
+ "noData": "No filtered orders",
+ "noFilteredOrders": "No filtered orders"
},
"copyTradingList": {
"title": "Copy Trading Config Management",
diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json
index ef77790..4675fbf 100644
--- a/frontend/src/locales/zh-CN/common.json
+++ b/frontend/src/locales/zh-CN/common.json
@@ -938,7 +938,8 @@
"marketEndDate": "市场截止时间超出限制",
"unknown": "未知原因"
},
- "noData": "暂无已过滤订单"
+ "noData": "暂无已过滤订单",
+ "noFilteredOrders": "暂无已过滤订单"
},
"copyTradingList": {
"title": "跟单配置管理",
@@ -1116,6 +1117,8 @@
"orderCount": "订单数",
"totalAmount": "总金额",
"statusBreakdown": "状态",
+ "allFullyMatched": "全部卖出",
+ "partiallyMatched": "部分卖出",
"markets": "个市场",
"fetchBuyOrdersFailed": "获取买入订单列表失败",
"fetchSellOrdersFailed": "获取卖出订单列表失败",
diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json
index 946c88d..c0ad6d2 100644
--- a/frontend/src/locales/zh-TW/common.json
+++ b/frontend/src/locales/zh-TW/common.json
@@ -938,7 +938,8 @@
"marketEndDate": "市場截止時間超出限制",
"unknown": "未知原因"
},
- "noData": "暫無已過濾訂單"
+ "noData": "暫無已過濾訂單",
+ "noFilteredOrders": "暫無已過濾訂單"
},
"copyTradingList": {
"title": "跟單配置管理",
@@ -1115,8 +1116,10 @@
"partiallySold": "部分賣出",
"orderCount": "訂單數",
"totalAmount": "總金額",
- "totalPnl": "總盈虧",
"statusBreakdown": "狀態",
+ "allFullyMatched": "全部賣出",
+ "partiallyMatched": "部分賣出",
+ "totalPnl": "總盈虧",
"markets": "個市場",
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",