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": "獲取賣出訂單列表失敗",