diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ff9b24e..5b77b2e 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -65,3 +65,54 @@ 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 }}" + + # 构建消息内容(使用 HTML 格式) + MESSAGE="✅ Docker 镜像构建成功"$'\n'$'\n'"📦 版本: ${VERSION}"$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔗 Release: 查看 Release"$'\n'"🐳 Docker 镜像: wrbug/polyhermes:${TAG}" + + # 添加 Release 名称(如果有) + if [ -n "$RELEASE_NAME" ] && [ "$RELEASE_NAME" != "null" ] && [ "$RELEASE_NAME" != "" ]; then + RELEASE_NAME_ESCAPED=$(echo "$RELEASE_NAME" | sed 's/&/\&/g' | sed 's//\>/g') + MESSAGE="${MESSAGE}"$'\n'$'\n'"📝 Release 名称: ${RELEASE_NAME_ESCAPED}" + fi + + # 发送 Telegram 消息(使用 jq 转义 JSON) + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MESSAGE" \ + '{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json + + # 检查发送结果 + if [ $? -eq 0 ]; then + RESPONSE=$(cat /tmp/telegram_response.json) + if echo "$RESPONSE" | grep -q '"ok":true'; then + echo "✅ Telegram 通知发送成功" + else + echo "❌ Telegram 通知发送失败: $RESPONSE" + # 构建成功,通知失败不应该导致整个 job 失败 + exit 0 + fi + else + echo "❌ 发送 Telegram 消息时发生错误" + # 构建成功,通知失败不应该导致整个 job 失败 + exit 0 + fi \ No newline at end of file diff --git a/.github/workflows/telegram-notify.yml b/.github/workflows/telegram-notify.yml new file mode 100644 index 0000000..7a66de7 --- /dev/null +++ b/.github/workflows/telegram-notify.yml @@ -0,0 +1,134 @@ +name: Telegram Notification on PR Merge + +on: + pull_request: + types: + - closed # 当 PR 被关闭(合并或关闭)时触发 + +jobs: + notify: + runs-on: ubuntu-latest + + # 只在 PR 被合并到 main 分支时执行 + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get PR details + id: pr_details + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + REPO="${{ github.repository }}" + + # 获取 PR 详细信息 + PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}") + + # 获取 PR 变更的文件列表 + FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files") + + # 提取 PR 描述(body),保留换行,限制长度 + PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""') + if [ ${#PR_BODY} -gt 500 ]; then + PR_BODY="${PR_BODY:0:500}..." + fi + + # 保存到输出变量(使用 base64 编码避免特殊字符问题) + echo "pr_body<> $GITHUB_OUTPUT + echo "$PR_BODY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Send Telegram notification + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + # 检查 PR 是否被合并(而不是仅关闭) + PR_MERGED="${{ github.event.pull_request.merged }}" + if [ "$PR_MERGED" != "true" ]; then + echo "ℹ️ PR 仅关闭,未合并,跳过通知" + exit 0 + fi + + # 检查必要的环境变量 + # 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890) + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知" + exit 0 + fi + + # 获取 PR 基本信息 + PR_NUMBER="${{ github.event.pull_request.number }}" + PR_TITLE="${{ github.event.pull_request.title }}" + PR_URL="${{ github.event.pull_request.html_url }}" + PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}" + + # 获取 PR 详细信息 + PR_BODY="${{ steps.pr_details.outputs.pr_body }}" + + # 转义 PR 标题和描述中的 HTML 特殊字符(但保留已有的 HTML 标签) + PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&/g' | sed 's//\>/g') + + # 构建消息内容(使用 HTML 格式) + MESSAGE="🚀 PR 已合并到 main 分支"$'\n'$'\n'"📝 PR #${PR_NUMBER}: ${PR_TITLE_ESCAPED}"$'\n'"🔗 链接: 查看 PR"$'\n'"🔀 合并提交: ${PR_MERGE_COMMIT:0:7}" + + # 添加 PR 描述(如果有) + if [ -n "$PR_BODY" ] && [ "$PR_BODY" != "null" ] && [ "$PR_BODY" != "" ]; then + # 使用 Python 将 Markdown 转换为 HTML(更可靠) + # 使用 echo 创建临时 Python 脚本,避免 heredoc 导致的 YAML 解析问题 + echo 'import sys' > /tmp/markdown_to_html.py + echo 'import re' >> /tmp/markdown_to_html.py + echo '' >> /tmp/markdown_to_html.py + echo 'text = sys.stdin.read()' >> /tmp/markdown_to_html.py + echo '' >> /tmp/markdown_to_html.py + echo 'text = text.replace("&", "&")' >> /tmp/markdown_to_html.py + echo 'text = text.replace("<", "<")' >> /tmp/markdown_to_html.py + echo 'text = text.replace(">", ">")' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"```([^`]+)```", r"
\\1
", text, flags=re.DOTALL)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"`([^`]+)`", r"\\1", text)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"\\*\\*([^*]+)\\*\\*", r"\\1", text)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"(?]*)\\*([^*<>]+)\\*(?![^<]*>)", r"\\1", text)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"\\[([^\\]]+)\\]\\(([^)]+)\\)", r"\\1", text)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"^### (.+)$", r"\\1", text, flags=re.MULTILINE)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"^## (.+)$", r"\\1", text, flags=re.MULTILINE)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"^# (.+)$", r"\\1", text, flags=re.MULTILINE)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"^- (.+)$", r"• \\1", text, flags=re.MULTILINE)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"^ - (.+)$", r" • \\1", text, flags=re.MULTILINE)' >> /tmp/markdown_to_html.py + echo 'text = re.sub(r"^ - (.+)$", r" • \\1", text, flags=re.MULTILINE)' >> /tmp/markdown_to_html.py + echo 'if len(text) > 1000:' >> /tmp/markdown_to_html.py + echo ' text = text[:1000] + "...\\n(内容已截断)"' >> /tmp/markdown_to_html.py + echo 'print(text, end="")' >> /tmp/markdown_to_html.py + PR_BODY_HTML=$(echo "$PR_BODY" | python3 /tmp/markdown_to_html.py) + + MESSAGE="${MESSAGE}"$'\n'$'\n'"📄 PR 描述:"$'\n'"${PR_BODY_HTML}" + fi + + # 发送 Telegram 消息(使用 jq 转义 JSON) + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MESSAGE" \ + '{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json + + # 检查发送结果 + if [ $? -eq 0 ]; then + RESPONSE=$(cat /tmp/telegram_response.json) + if echo "$RESPONSE" | grep -q '"ok":true'; then + echo "✅ Telegram 通知发送成功" + else + echo "❌ Telegram 通知发送失败: $RESPONSE" + exit 1 + fi + else + echo "❌ 发送 Telegram 消息时发生错误" + exit 1 + fi + diff --git a/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 88d12ae..ad6fa12 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 @@ -199,22 +199,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 +584,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}")