diff --git a/.cursor/skills/README.md b/.cursor/skills/README.md index 9830a727..5f5f8212 100644 --- a/.cursor/skills/README.md +++ b/.cursor/skills/README.md @@ -14,6 +14,10 @@ │ └── package.json ├── backend/ # 后端相关 skill └── common/ # 通用 skill + └── create-release/ # 创建 GitHub Release + ├── SKILL.md + └── scripts/ + └── create-release.sh ``` - **SKILL.md**:YAML frontmatter(`name`、`description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。 @@ -27,7 +31,8 @@ ## 示例 -- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。 +- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key +- `common/create-release/SKILL.md` + `common/create-release/scripts/` — 创建 GitHub Release ## 运行 check-i18n-keys @@ -37,3 +42,17 @@ npm install npm run check-i18n ``` +## 运行 create-release + +```bash +cd .cursor/skills/common/create-release/scripts +./create-release.sh -t v1.0.0 -T "Release v1.0.0" -d "发布说明" +``` + +参数说明: +- `-t` 版本号(必需,格式 v1.0.0) +- `-T` Release 标题 +- `-d` Release 描述 +- `-p` 标记为 Pre-release(自动加 -beta 后缀) +- `-y` 无交互模式 + diff --git a/.cursor/skills/common/create-release/SKILL.md b/.cursor/skills/common/create-release/SKILL.md new file mode 100644 index 00000000..3b89d1a2 --- /dev/null +++ b/.cursor/skills/common/create-release/SKILL.md @@ -0,0 +1,341 @@ +--- +name: create-release +description: 创建 PolyHermes 项目的 GitHub Release。当用户要求发布版本、创建 release、打 tag 或发布新版本时使用。 +--- + +# Create Release + +创建 PolyHermes 项目的 GitHub Release,包括创建 Git tag、推送 tag、创建 GitHub Release(支持 pre-release),并自动在 Issue #1 发布公告。 + +## 使用时机 + +- 用户要求「发布版本」「创建 release」「打 tag」「发布新版本」时 +- 用户要求「创建 pre-release」「beta 版本」时 +- 用户提到「v1.x.x」等版本号相关操作时 + +## 前置条件 + +1. **GitHub CLI 已安装**:确保 `gh` 命令可用 +2. **已登录 GitHub**:运行 `gh auth status` 确认 +3. **工作目录干净**:建议先提交所有更改 + +## 指令 + +### 步骤 1:收集发布信息 + +询问用户以下信息: +- 版本号(格式:vX.Y.Z,如 v1.0.0) +- 是否为 Pre-release(测试版本) +- Release 标题和描述(可选,如未提供则自动生成) + +### 步骤 2:生成 Release 内容 + +**重要**:如果用户未提供描述,需要根据 Git commits 自动生成。 + +1. **获取上一个版本的 tag**: + ```bash + git describe --tags --abbrev=0 HEAD + ``` + +2. **获取版本间的 commits**: + ```bash + git log ..HEAD --oneline --no-merges + ``` + +3. **过滤 commit 规则**: + - **排除**:版本内新增功能的修复 commit + - **判断方法**:如果一个 commit 的消息包含「fix」「修复」「bugfix」等关键词,且是针对同一版本内新增代码的修复,则不包含 + - **保留**:新功能、性能优化、重构、文档更新等 + +4. **生成中英文 Release 内容**: + - 格式要求:**中文在上,英文在下** + - 使用分隔线 `---` 分隔中英文部分 + - 按功能类型分组(新功能、改进、修复等) + + 示例格式: + ```markdown + ## 新功能 + + - 添加了 A 功能 + - 支持了 B 操作 + + ## 改进 + + - 优化了 C 性能 + + --- + + ## New Features + + - Added feature A + - Supported operation B + + ## Improvements + + - Optimized performance C + ``` + +### 步骤 3:运行发布脚本 + +在项目根目录下执行: + +```bash +cd .cursor/skills/common/create-release/scripts && \ +chmod +x create-release.sh && \ +./create-release.sh -t [-T ""] [-d "<DESCRIPTION>"] [-p] [-y] +``` + +### 步骤 4:发布公告到 Issue #1 + +**重要**:Release 创建成功后,必须自动在 Issue #1 下发布公告 comment。 + +1. **生成公告内容**(面向用户,通俗易懂): + + 公告格式模板: + ```markdown + # 🎉 PolyHermes vX.X.X 版本发布公告 + + ## 📅 发布日期 + + YYYY年MM月DD日 + + --- + + ## ✨ 本次更新亮点 + + ### 🚀 新功能 + + **功能名称** + - 用通俗的语言描述这个功能是什么 + - 用户能从中获得什么好处 + - 如何使用这个功能 + + ### 🔧 改进优化 + + - 优化了 XXX,现在 XXX 更快/更稳定了 + - 改进了 XXX 体验,操作更简单了 + + ### 🐛 问题修复 + + - 修复了 XXX 问题,不再出现 XXX 情况 + + --- + + ## 📦 如何更新 + + ### Docker 部署(推荐) + + ```bash + # 拉取最新镜像 + docker pull wrbug/polyhermes:vX.X.X + + # 重启服务 + docker-compose -f docker-compose.prod.yml down + docker-compose -f docker-compose.prod.yml up -d + ``` + + --- + + ## ⚠️ 安全提醒 + + **请务必使用官方 Docker 镜像源,避免财产损失!** + + **官方镜像地址**:`wrbug/polyhermes` + + --- + + ## 📚 相关链接 + + - **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/vX.X.X + - **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes + ``` + +2. **公告内容编写原则**: + + - ✅ **通俗易懂**:避免技术术语,用用户能理解的语言 + - ✅ **突出价值**:告诉用户这个更新对他们有什么好处 + - ✅ **简洁明了**:每个功能点用 1-2 句话说明 + - ✅ **包含操作指引**:告诉用户如何使用新功能 + - ✅ **中英文双语**:中文在上,英文在下(可选) + - ❌ **避免**:commit hash、代码细节、内部实现 + +3. **执行发布公告命令**: + + ```bash + gh issue comment 1 --repo WrBug/PolyHermes --body "$(cat <<'EOF' + # 🎉 PolyHermes vX.X.X 版本发布公告 + + [公告内容...] + + --- + EOF + )" + ``` + + 或使用文件方式(内容较长时推荐): + + ```bash + echo "[公告内容...]" > /tmp/announcement.md + gh issue comment 1 --repo WrBug/PolyHermes --body-file /tmp/announcement.md + ``` + +### 参数说明 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `-t, --tag` | 版本号(必需) | `-t v1.0.0` | +| `-T, --title` | Release 标题 | `-T "Release v1.0.0"` | +| `-d, --description` | Release 描述 | `-d "## 新功能\n- 功能1"` | +| `-f, --description-file` | 从文件读取描述 | `-f CHANGELOG.md` | +| `-p, --prerelease` | 标记为 Pre-release(自动加 -beta 后缀) | `-p` | +| `-y, --yes` | 无交互模式 | `-y` | + +## 版本号格式 + +- 必须格式:`v数字.数字.数字`(如 v1.0.0, v1.10.2, v1.1.12) +- 如果指定 `--prerelease`,会自动拼接 `-beta` 后缀(如 v1.0.1 → v1.0.1-beta) + +## Release 内容生成规则 + +### 中英文格式 + +Release 描述**必须**使用中英文双语格式: + +```markdown +## 中文标题 + +- 内容项1 +- 内容项2 + +--- + +## English Title + +- Item 1 +- Item 2 +``` + +### Commit 过滤规则 + +**需要排除的 commit 类型**: + +1. **版本内修复**:对同一版本新增功能的后续修复 + - 例如:v1.0.1 新增了功能 A,然后有一个 commit 修复功能 A 的 bug → 不包含 + - 判断依据:commit 消息包含「fix」「修复」「bugfix」且相关功能在本版本新增 + +2. **琐碎修改**: + - typo 修正 + - 代码格式调整 + - 注释更新 + +**需要保留的 commit 类型**: + +1. 新功能(feat、feature) +2. 改进/优化(improve、optimize、enhance) +3. 重要 bug 修复(针对旧版本的 bug) +4. 重构(refactor) +5. 文档更新(docs) + +### 分组建议 + +- **新功能 / New Features** +- **改进 / Improvements** +- **修复 / Bug Fixes**(仅包含对旧版本 bug 的修复) +- **其他 / Others** + +## 公告内容示例 + +以下是一个面向用户的公告示例: + +```markdown +# 🎉 PolyHermes v1.2.0 版本发布公告 + +## 📅 发布日期 + +2026年3月2日 + +--- + +## ✨ 本次更新亮点 + +### 🚀 新功能 + +**系统自动更新** +- 现在可以在网页上直接更新系统,无需手动重启 Docker +- 更新过程约 30-60 秒,系统会自动处理 +- 如果更新失败,系统会自动恢复到旧版本 + +**RPC 节点管理** +- 可以在系统设置中添加、编辑、删除自定义 RPC 节点 +- 可以随时启用或禁用节点 +- 系统会自动选择可用的节点 + +### 🔧 改进优化 + +- **更快的跟单响应**:通过实时监听链上交易,跟单速度提升到秒级 +- **更准确的盈亏统计**:系统会自动追踪实际成交价,统计数据更准确 +- **内存占用优化**:修复了内存泄漏问题,系统可以长时间稳定运行 + +### 🐛 问题修复 + +- 修复了部分市场无法正确查询价格的问题 +- 修复了卖出订单偶发失败的问题 + +--- + +## 📦 如何更新 + +### 方式一:网页更新(推荐) + +1. 登录系统,进入 **系统设置** → **系统更新** +2. 点击 **检查更新** +3. 如果有新版本,点击 **立即升级** +4. 等待更新完成即可 + +### 方式二:Docker 更新 + +```bash +docker pull wrbug/polyhermes:v1.2.0 +docker-compose -f docker-compose.prod.yml down +docker-compose -f docker-compose.prod.yml up -d +``` + +--- + +## ⚠️ 安全提醒 + +**请务必使用官方 Docker 镜像源!** + +官方镜像:`wrbug/polyhermes` + +--- + +## 📚 相关链接 + +- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.2.0 +- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes +``` + +## 发布流程 + +1. 验证版本号格式 +2. 检查 Git 工作目录状态 +3. 检查 tag 是否已存在 +4. 生成 Release 内容(中英文) +5. 创建本地 tag +6. 推送 tag 到远程 +7. 创建 GitHub Release +8. **生成面向用户的公告内容** +9. **发布公告到 Issue #1** +10. 返回 Release URL 和公告链接 + +## 注意事项 + +- Pre-release 版本不会触发 Telegram 通知 +- GitHub Actions 会自动触发构建流程 +- 如果 tag 已存在,会提示是否删除并重新创建 +- **必须**在 Release 创建成功后发布公告到 Issue #1 + +## 可选目录说明 + +- `scripts/`:包含 `create-release.sh` 发布脚本 diff --git a/create-release.sh b/.cursor/skills/common/create-release/scripts/create-release.sh similarity index 99% rename from create-release.sh rename to .cursor/skills/common/create-release/scripts/create-release.sh index 899d2786..3191ca84 100755 --- a/create-release.sh +++ b/.cursor/skills/common/create-release/scripts/create-release.sh @@ -346,4 +346,3 @@ main() { # 执行主函数 main "$@" - diff --git a/.github/ISSUE_TEMPLATE/ai-bug-report.yml b/.github/ISSUE_TEMPLATE/ai-bug-report.yml new file mode 100644 index 00000000..df2d7fce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ai-bug-report.yml @@ -0,0 +1,201 @@ +--- +name: 🤖 Bug Report for AI Fix / AI Bug 报告 +description: Bug 报告模板(提交后请手动添加 'fix via ai' 标签触发自动修复) +title: '[Bug] / ' +assignees: [] +body: + - type: markdown + attributes: + value: | + ## Bug Report Template 🐛 + + 本模板用于报告 PolyHermes 项目的 bug。 + + ⚠️ **Important / 重要提示**: + - After submission, if you need AI auto-fix, please manually add the `fix via ai` label + / 提交后,如需 AI 自动修复,请手动添加 `fix via ai` 标签 + - AI fixes will be on the `fix_issues_by_ai` branch / AI 修复将在 `fix_issues_by_ai` 分支上进行 + - All AI fixes require human review before merging / 所有 AI 修复需要人工审核后才能合并 + - Security vulnerabilities, database migrations, major changes are not recommended for AI auto-fix + / 涉及安全漏洞、数据库迁移、重大变更等问题不建议使用 AI 自动修复 + + --- + + 本模板用于报告 PolyHermes 项目的 bug。 + + This template is for reporting bugs in the PolyHermes project. + + - type: textarea + id: description + attributes: + label: 📝 Bug Description / Bug 描述 + description: Clearly and concisely describe the bug / 清晰简洁地描述这个 bug + placeholder: Describe the bug you encountered / 描述你遇到的问题... + validations: + required: true + + - type: dropdown + id: type + attributes: + label: 🎯 Bug Type / 问题类型 + description: Select the type of bug / 选择问题类型 + options: + - Frontend bug (UI/UX/interaction) / 前端 bug (UI/UX/交互问题) + - Backend bug (API/logic/data) / 后端 bug (API/逻辑/数据处理) + - Database issue / 数据库问题 + - Performance issue / 性能问题 + - Configuration/Deployment / 配置/部署问题 + - Documentation / 文档问题 + - Other / 其他 + validations: + required: true + + - type: dropdown + id: scope + attributes: + label: 📍 Affected Scope / 影响范围 + description: Select the scope of impact / 选择问题影响范围 + options: + - Specific page/function only / 仅影响特定页面/功能 + - Entire system / 影响整个系统 + - Specific user role / 影响特定用户角色 + - Only in specific environment / 仅在特定环境下重现 + validations: + required: true + + - type: textarea + id: steps + attributes: + label: 🔍 Steps to Reproduce / 复现步骤 + description: Provide clear, detailed steps to reproduce the bug / 提供清晰、详细的步骤来重现这个 bug + placeholder: | + 1. Visit page: `...` / 访问页面:`...` + 2. Click button: `...` / 点击按钮:`...` + 3. Input data: `...` / 输入数据:`...` + 4. Submit form: `...` / 提交表单:`...` + 5. Observe error: `...` / 观察到错误:`...` + validations: + required: true + + - type: dropdown + id: frequency + attributes: + label: Reproduction Frequency / 复现频率 + options: + - Always reproducible (100%) / 总是能复现 (100%) + - Frequently reproducible (50%+) / 经常能复现 (50%+) + - Occasionally reproducible (<50%) / 偶尔能复现 (<50%) + - Hard to reproduce / 很难复现 + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 💻 Expected Behavior / 预期行为 + description: Describe what you expected to happen / 描述你期望发生什么 + placeholder: What should happen / 应该发生什么... + validations: + required: true + + - type: textarea + id: actual + attributes: + label: ❌ Actual Behavior / 实际行为 + description: Describe what actually happened / 描述实际发生了什么 + placeholder: What actually happened / 实际发生了什么... + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: 📸 Screenshots / Recordings / 截图/录屏 + description: If applicable, add screenshots or recordings to illustrate the problem (drag and drop files here) + / 如果适用,添加截图或录屏来说明问题(可以拖拽文件到这里) + placeholder: Add screenshots or recordings / 添加截图或录屏... + + - type: textarea + id: environment + attributes: + label: 🌐 Environment / 环境 + description: Provide relevant environment information / 提供相关环境信息 + value: | + **Browser (for frontend issues) / 浏览器(前端问题):** + - Browser: ______ / 浏览器:______ + - Browser version: ______ / 浏览器版本:______ + - Operating System: ______ / 操作系统:______ + + **Backend Environment (for backend issues) / 后端环境(后端问题):** + - Node.js version: ______ / Node.js 版本:______ + - Database version: ______ / 数据库版本:______ + - Docker version (if used): ______ / Docker 版本(如果使用):______ + - Other relevant dependencies: ______ / 其他相关依赖版本:______ + validations: + required: false + + - type: textarea + id: related-files + attributes: + label: 📁 Related Files / Code / 相关文件/代码 + description: Provide relevant file paths or code snippets / 提供可能涉及的文件路径或相关代码片段 + placeholder: | + Possibly related files / 可能涉及的文件: + - frontend/src/components/... + - backend/src/main/kotlin/... + + Error logs / 错误日志: + ``` + Paste error logs here / 粘贴错误日志 + ``` + validations: + required: false + + - type: textarea + id: suggestions + attributes: + label: 🎯 Fix Suggestions (Optional) / 修复建议(可选) + description: If you have fix ideas, describe them briefly / 如果你有修复思路,可以在这里简单描述 + placeholder: | + Suggest adding ZZZ check in the YYY method of file XXX + / 建议在 XXX 文件的 YYY 方法中,添加 ZZZ 检查 + ... + + - type: dropdown + id: priority + attributes: + label: 🚨 Priority / 优先级 + options: + - 🔴 High - Blocking core functionality, affects user experience / 高 - 阻塞核心功能,影响用户体验 + - 🟡 Medium - Limited functionality but not blocking / 中 - 功能受限但不阻塞 + - 🟢 Low - Minor issue, doesn't affect usage / 低 - 小问题,不影响使用 + validations: + required: true + + - type: textarea + id: additional + attributes: + label: 📝 Additional Information / 补充说明 + description: Any other information that helps AI understand and fix the issue / 任何其他有助于 AI 理解和修复问题的信息 + placeholder: | + - Was this bug introduced recently? / 这个 bug 是最近引入的吗? + - Is it related to a specific PR or commit? / 是否与某个特定的 PR 或 commit 相关? + - Does it only occur with specific datasets or users? / 是否只在特定数据集或特定用户情况下出现? + / ... + Any other context / 其他任何上下文信息... + + - type: checkboxes + id: ai-fix-approval + attributes: + label: 🤖 AI Auto-Fix Confirmation / AI 自动修复确认 + description: If you need AI to auto-fix this issue, check the options below (remember to add 'fix via ai' label after submission) + / 如需 AI 自动修复此 issue,请勾选下方选项(提交后记得添加 'fix via ai' 标签) + options: + - label: | + I understand the AI auto-fix workflow and agree to manually review the AI-created PR + / 我已了解 AI 自动修复的工作流程,并同意在 AI 创建 PR 后进行人工审核 + required: false + - label: | + This issue is suitable for AI auto-fix (not a security vulnerability, not a database migration, not a major change) + / 此问题适合 AI 自动修复(非安全漏洞、非数据库迁移、非重大变更) + required: false diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index df00230d..8211e3bf 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -6,14 +6,6 @@ on: - 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 @@ -36,22 +28,6 @@ jobs: with: 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: | @@ -100,53 +76,6 @@ jobs: echo "📦 这是正式版本: $TAG_NAME" fi - - name: Send Telegram notification (build started) - 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 }} - run: | - # 检查必要的环境变量 - if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then - echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知" - exit 0 - fi - - # 获取构建信息 - TAG="${{ steps.extract_version.outputs.TAG }}" - - if [ "${{ github.event_name }}" = "release" ]; then - RELEASE_URL="${{ github.event.release.html_url }}" - MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>" - else - WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>" - 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 - # ============ 编译前后端产物 ============ - name: Setup JDK 17 uses: actions/setup-java@v4 @@ -263,22 +192,66 @@ jobs: checksums.txt retention-days: 30 + # ============ 发送产物上传成功通知 ============ + - name: Send Telegram notification (package uploaded) + if: steps.extract_version.outputs.IS_PRERELEASE == 'false' + 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 + + # 获取构建信息 + TAG="${{ steps.extract_version.outputs.TAG }}" + + if [ "${{ github.event_name }}" = "release" ]; then + RELEASE_URL="${{ github.event.release.html_url }}" + MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📍 <b>Update Path:</b> System Management → Overview → Check for Updates"$'\n'$'\n'"🐳 Building Docker image..." + else + WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'$'\n'"🐳 Building Docker image..." + 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 0 + fi + else + echo "❌ 发送 Telegram 消息时发生错误" + exit 0 + fi + + # ============ Docker 构建 ============ - 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 构建上下文..." # 确保构建产物存在且可访问 @@ -295,7 +268,7 @@ jobs: ls -lh backend/build/libs/*.jar - name: Build and push Docker image - if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker' + id: docker_build uses: docker/build-push-action@v5 with: context: . @@ -314,13 +287,8 @@ jobs: 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 + # ============ 发送 Docker 构建成功通知 ============ + - name: Send Telegram notification (Docker build completed) if: steps.extract_version.outputs.IS_PRERELEASE == 'false' env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} @@ -335,25 +303,14 @@ jobs: # 获取构建信息 VERSION="${{ steps.extract_version.outputs.VERSION }}" TAG="${{ steps.extract_version.outputs.TAG }}" - BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}" - - # 构建消息内容(仅包含关键信息) DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md" if [ "${{ github.event_name }}" = "release" ]; then RELEASE_URL="${{ github.event.release.html_url }}" - if [ "$BUILD_TYPE" = "package-and-docker" ]; then - MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>" - else - MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新" - fi + MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>" else WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - if [ "$BUILD_TYPE" = "package-and-docker" ]; then - MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>" - else - MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新" - fi + MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>" fi # 发送 Telegram 消息(使用 jq 转义 JSON) @@ -371,11 +328,9 @@ jobs: echo "✅ Telegram 通知发送成功" else echo "❌ Telegram 通知发送失败: $RESPONSE" - # 构建成功,通知失败不应该导致整个 job 失败 exit 0 fi else echo "❌ 发送 Telegram 消息时发生错误" - # 构建成功,通知失败不应该导致整个 job 失败 exit 0 - fi \ No newline at end of file + fi diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt index a3d14c18..ab638724 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -212,6 +212,7 @@ data class NewOrderResponse( val success: Boolean, // boolean indicating if server-side error @SerializedName("errorMsg") val errorMsg: String? = null, // error message in case of unsuccessful placement + val error: String? = null, // error message (alternative field, e.g. "Trading restricted in your region...") @SerializedName("orderID") val orderId: String? = null, // id of order(API 返回字段名为 orderID) @SerializedName("transactionsHashes") @@ -222,7 +223,17 @@ data class NewOrderResponse( val takingAmount: String? = null, // taking amount @SerializedName("makingAmount") val makingAmount: String? = null // making amount -) +) { + /** + * 获取错误信息的便捷方法 + * 优先返回 errorMsg,其次返回 error,最后返回默认消息 + */ + fun getErrorMessage(): String { + return errorMsg?.takeIf { it.isNotBlank() } + ?: error?.takeIf { it.isNotBlank() } + ?: "创建订单失败" + } +} /** * 旧的订单请求格式(已废弃,保留用于兼容) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 40c30e90..87ea5b54 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -13,10 +13,15 @@ import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse +import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest +import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService +import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService import org.slf4j.LoggerFactory import org.springframework.context.MessageSource import org.springframework.http.ResponseEntity @@ -24,12 +29,14 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import kotlinx.coroutines.runBlocking @RestController @RequestMapping("/api/crypto-tail-strategy") class CryptoTailStrategyController( private val cryptoTailStrategyService: CryptoTailStrategyService, private val cryptoTailMonitorService: CryptoTailMonitorService, + private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService, private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, private val messageSource: MessageSource ) { @@ -125,6 +132,26 @@ class CryptoTailStrategyController( } } + @PostMapping("/pnl-curve") + fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> { + return try { + if (request.strategyId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)) + } + val result = cryptoTailStrategyService.getPnlCurve(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询收益曲线失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询收益曲线异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource)) + } + } + @PostMapping("/triggers") fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> { return try { @@ -216,4 +243,41 @@ class CryptoTailStrategyController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) } } + + /** + * 手动下单 + * 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单 + */ + @PostMapping("/manual-order") + fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> { + return runBlocking { + try { + if (request.strategyId <= 0) { + return@runBlocking ResponseEntity.ok( + ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource) + ) + } + val result = cryptoTailStrategyExecutionService.manualOrder(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("手动下单失败: ${e.message}", e) + val code = when (e.message) { + "策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND + "当前周期已下单" -> ErrorCode.PARAM_ERROR + "价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR + "数量不能少于 1" -> ErrorCode.PARAM_ERROR + "总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR + "总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR + else -> ErrorCode.SERVER_ERROR + } + ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("手动下单异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt index 0f42d289..03ee5ea5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt @@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.system.NotificationConfigService +import com.wrbug.polymarketbot.service.system.NotificationTemplateService import com.wrbug.polymarketbot.service.system.TelegramNotificationService import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.* class NotificationController( private val notificationConfigService: NotificationConfigService, private val telegramNotificationService: TelegramNotificationService, + private val notificationTemplateService: NotificationTemplateService, private val messageSource: MessageSource ) { @@ -335,6 +337,155 @@ class NotificationController( )) } } + + // ==================== 模板相关 API ==================== + + /** + * 获取所有模板类型 + */ + @PostMapping("/templates/types") + fun getTemplateTypes(): ResponseEntity<ApiResponse<List<TemplateTypeInfoDto>>> { + return try { + val types = notificationTemplateService.getTemplateTypes() + ResponseEntity.ok(ApiResponse.success(types)) + } catch (e: Exception) { + logger.error("获取模板类型失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 获取所有模板 + */ + @PostMapping("/templates/list") + fun getTemplates(): ResponseEntity<ApiResponse<List<NotificationTemplateDto>>> { + return try { + val templates = notificationTemplateService.getAllTemplates() + ResponseEntity.ok(ApiResponse.success(templates)) + } catch (e: Exception) { + logger.error("获取模板列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 获取单个模板 + */ + @PostMapping("/templates/detail") + fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val template = notificationTemplateService.getTemplate(request.templateType) + if (template == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(template)) + } + } catch (e: Exception) { + logger.error("获取模板详情失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 获取模板可用变量 + */ + @PostMapping("/templates/variables") + fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<TemplateVariablesResponse>> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val variables = notificationTemplateService.getTemplateVariables(request.templateType) + if (variables == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(variables)) + } + } catch (e: Exception) { + logger.error("获取模板变量失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 更新模板 + */ + @PostMapping("/templates/update") + fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity<ApiResponse<NotificationTemplateDto>> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + if (request.templateContent.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空")) + } + + val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent) + ResponseEntity.ok(ApiResponse.success(template)) + } catch (e: Exception) { + logger.error("更新模板失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 重置模板为默认 + */ + @PostMapping("/templates/reset") + fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val template = notificationTemplateService.resetTemplate(request.templateType) + if (template == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(template)) + } + } catch (e: Exception) { + logger.error("重置模板失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 发送模板测试消息 + */ + @PostMapping("/templates/test") + fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity<ApiResponse<Boolean>> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val success = runBlocking { + notificationTemplateService.sendTestMessage(request.templateType, request.templateContent) + } + + if (success) { + ResponseEntity.ok(ApiResponse.success(true)) + } else { + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_TEST_FAILED, + messageSource = messageSource + )) + } + } catch (e: Exception) { + logger.error("发送模板测试消息失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_TEST_FAILED, + customMsg = "发送测试消息失败:${e.message}", + messageSource = messageSource + )) + } + } } /** @@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest( val id: Long ) +/** + * 模板详情请求 + */ +data class TemplateDetailRequest( + val templateType: String +) + +/** + * 更新模板请求(带类型) + */ +data class UpdateTemplateRequestWithId( + val templateType: String, + val templateContent: String +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt new file mode 100644 index 00000000..38074c7a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt @@ -0,0 +1,21 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略手动下单请求 + */ +data class CryptoTailManualOrderRequest( + /** 策略ID */ + val strategyId: Long = 0L, + /** 当前周期开始时间 (Unix 秒) */ + val periodStartUnix: Long = 0L, + /** 下单方向: UP or DOWN */ + val direction: String = "UP", + /** 下单价格 */ + val price: String = "0", + /** 下单数量 */ + val size: String = "1", + /** 市场标题(用于记录) */ + val marketTitle: String = "", + /** Token IDs */ + val tokenIds: List<String> = emptyList() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt new file mode 100644 index 00000000..12000ef8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt @@ -0,0 +1,31 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略手动下单响应 + */ +data class CryptoTailManualOrderResponse( + /** 是否成功 */ + val success: Boolean = false, + /** 订单ID */ + val orderId: String? = null, + /** 提示消息 */ + val message: String = "", + /** 下单详情 */ + val orderDetails: ManualOrderDetails? = null +) + +/** + * 手动下单详情 + */ +data class ManualOrderDetails( + /** 策略ID */ + val strategyId: Long = 0L, + /** 方向 */ + val direction: String = "", + /** 下单价格 */ + val price: String = "", + /** 下单数量 */ + val size: String = "", + /** 总金额 */ + val totalAmount: String = "" +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt index 3761a2ec..56ec5c58 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt @@ -5,7 +5,9 @@ package com.wrbug.polymarketbot.dto */ data class CryptoTailMonitorInitRequest( /** 策略ID */ - val strategyId: Long = 0L + val strategyId: Long = 0L, + /** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */ + val periodStartUnix: Long? = null ) /** @@ -55,7 +57,11 @@ data class CryptoTailMonitorInitResponse( /** 当前时间 (毫秒时间戳) */ val currentTimestamp: Long = System.currentTimeMillis(), /** 是否启用 */ - val enabled: Boolean = true + val enabled: Boolean = true, + /** 投入金额模式: FIXED or RATIO */ + val amountMode: String? = null, + /** 投入金额数值 */ + val amountValue: String? = null ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt index 1ed4c24d..7ae88f29 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -167,3 +167,45 @@ data class CryptoTailMarketOptionDto( val periodStartUnix: Long = 0L, val endDate: String? = null ) + +/** + * 收益曲线请求 + * @param strategyId 策略ID + * @param startDate 开始时间(毫秒时间戳),null 表示不限制 + * @param endDate 结束时间(毫秒时间戳),null 表示不限制 + */ +data class CryptoTailPnlCurveRequest( + val strategyId: Long = 0L, + val startDate: Long? = null, + val endDate: Long? = null +) + +/** + * 收益曲线单点数据 + */ +data class CryptoTailPnlCurvePoint( + /** 时间点(毫秒时间戳,结算时间或创建时间) */ + val timestamp: Long = 0L, + /** 累计收益 USDC */ + val cumulativePnl: String = "0", + /** 当笔收益 USDC */ + val pointPnl: String = "0", + /** 截至该点累计已结算笔数 */ + val settledCount: Long = 0L +) + +/** + * 收益曲线响应 + */ +data class CryptoTailPnlCurveResponse( + val strategyId: Long = 0L, + val strategyName: String = "", + /** 筛选范围内总已实现收益 USDC */ + val totalRealizedPnl: String = "0", + val settledCount: Long = 0L, + val winCount: Long = 0L, + val winRate: String? = null, + /** 最大回撤 USDC(正数表示回撤幅度) */ + val maxDrawdown: String? = null, + val curveData: List<CryptoTailPnlCurvePoint> = emptyList() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt new file mode 100644 index 00000000..d406cb76 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt @@ -0,0 +1,63 @@ +package com.wrbug.polymarketbot.dto + +/** + * 消息模板 DTO + */ +data class NotificationTemplateDto( + val id: Long? = null, + val templateType: String, // 模板类型 + val templateContent: String, // 模板内容 + val isDefault: Boolean = false, // 是否使用默认模板 + val createdAt: Long? = null, + val updatedAt: Long? = null +) + +/** + * 模板变量 DTO + */ +data class TemplateVariableDto( + val key: String, // 变量名,如 account_name + val category: String, // 分类:common, order, copy_trading, redeem, error + val sortOrder: Int = 0 // 排序顺序 +) + +/** + * 模板变量分类 DTO + */ +data class TemplateVariableCategoryDto( + val key: String, // 分类 key + val sortOrder: Int = 0 // 排序顺序 +) + +/** + * 模板变量列表响应 + */ +data class TemplateVariablesResponse( + val templateType: String, // 模板类型 + val categories: List<TemplateVariableCategoryDto>, // 分类列表 + val variables: List<TemplateVariableDto> // 变量列表 +) + +/** + * 更新模板请求 + */ +data class UpdateTemplateRequest( + val templateContent: String // 模板内容 +) + +/** + * 测试模板请求 + */ +data class TestTemplateRequest( + val templateType: String, // 模板类型 + val templateContent: String? = null // 可选,如果不提供则使用已保存的模板 +) + +/** + * 模板类型信息 + */ +data class TemplateTypeInfoDto( + val type: String, // 模板类型 + val name: String, // 类型名称 + val description: String // 类型描述 +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt index 96a33fd1..10bfa792 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt @@ -51,18 +51,21 @@ data class OrderPushMessage( /** * 订单详情(通过 API 获取) + * @param price 订单限价(用户提交的买入/卖出价) + * @param avgFilledPrice 实际成交价 = original_size * price / size_matched(有成交时优先用于推送展示) */ data class OrderDetailDto( val id: String, // 订单 ID val market: String, // 市场 ID (condition ID) val side: String, // BUY/SELL - val price: String, // 价格 + val price: String, // 订单限价 val size: String, // 订单大小 val filled: String, // 已成交数量 val status: String, // 订单状态 val createdAt: String, // 创建时间(ISO 8601 格式) val marketName: String? = null, // 市场名称(通过 Data API 获取) val marketSlug: String? = null, // 市场 slug - val marketIcon: String? = null // 市场图标 + val marketIcon: String? = null, // 市场图标 + val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用) ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt index e8020df3..472271e2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt @@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger( @Column(name = "fail_reason", length = 500) val failReason: String? = null, + @Column(name = "trigger_type", nullable = false, length = 20) + val triggerType: String = "AUTO", + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt new file mode 100644 index 00000000..1681b3f1 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt @@ -0,0 +1,30 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 消息推送模板实体 + * 用于存储用户自定义的消息模板 + */ +@Entity +@Table(name = "notification_templates") +data class NotificationTemplate( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "template_type", unique = true, nullable = false, length = 50) + val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN + + @Column(name = "template_content", nullable = false, columnDefinition = "TEXT") + var templateContent: String, // 模板内容,支持 {{variable}} 变量 + + @Column(name = "is_default", nullable = false) + var isDefault: Boolean = false, // 是否使用默认模板 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt index b248ff74..185efa68 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy /** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */ @Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex") fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long + + /** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */ + @Query( + "SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " + + "AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " + + "ORDER BY COALESCE(t.settledAt, t.createdAt) ASC" + ) + fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc( + @Param("strategyId") strategyId: Long, + @Param("start") start: Long, + @Param("end") end: Long + ): List<CryptoTailStrategyTrigger> } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationTemplateRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationTemplateRepository.kt new file mode 100644 index 00000000..00b1b4f1 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationTemplateRepository.kt @@ -0,0 +1,11 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.NotificationTemplate +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface NotificationTemplateRepository : JpaRepository<NotificationTemplate, Long> { + fun findByTemplateType(templateType: String): NotificationTemplate? + fun existsByTemplateType(templateType: String): Boolean +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index d0c8cb51..8ab6a91e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -10,7 +10,10 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.eq import com.wrbug.polymarketbot.util.gt import com.wrbug.polymarketbot.util.JsonUtils +import com.wrbug.polymarketbot.util.fromJson import com.wrbug.polymarketbot.util.getEventSlug +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive import com.wrbug.polymarketbot.service.common.PolymarketClobService import com.wrbug.polymarketbot.service.common.BlockchainService import com.wrbug.polymarketbot.service.common.MarketService @@ -1331,13 +1334,22 @@ class AccountService( // 使用当前时间作为订单创建时间 val orderTime = System.currentTimeMillis() + + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}") + null + } telegramNotificationService?.sendOrderSuccessNotification( orderId = orderId, marketTitle = marketTitle, marketId = request.marketId, marketSlug = marketSlug, - side = request.side, + side = "SELL", // 手动卖出订单,方向固定为 SELL + outcome = request.side, // request.side 是市场方向(YES/NO) price = sellPrice, // 直接传递卖出价格 size = sellQuantity.toPlainString(), // 直接传递卖出数量 accountName = account.accountName, @@ -1348,7 +1360,8 @@ class AccountService( apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null }, walletAddressForApi = account.walletAddress, locale = locale, - orderTime = orderTime // 使用订单创建时间 + orderTime = orderTime, // 使用订单创建时间 + availableBalance = availableBalance ) } catch (e: Exception) { logger.warn("发送订单成功通知失败: ${e.message}", e) @@ -1368,7 +1381,7 @@ class AccountService( ) ) } else { - val errorMsg = response.errorMsg ?: "未知错误" + val errorMsg = response.getErrorMessage() val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, errorMsg=$errorMsg" logger.error(fullErrorMsg) @@ -1413,6 +1426,14 @@ class AccountService( } catch (e: Exception) { null } + + // 尝试从 errorBody 解析 error 字段(使用 Gson) + val apiError = try { + (errorBody?.fromJson<JsonObject>()?.get("error") as? JsonPrimitive)?.asString + } catch (e: Exception) { + null + } + val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}" logger.error(fullErrorMsg) @@ -1431,8 +1452,10 @@ class AccountService( java.util.Locale("zh", "CN") // 默认简体中文 } - // 只传递后端返回的 msg,不传递完整堆栈 - val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败" + // 优先使用解析的 API error,其次使用响应体的 errorMsg,最后使用默认消息 + val errorMsg = apiError + ?: orderResponse.body()?.getErrorMessage() + ?: "创建订单失败 (HTTP ${orderResponse.code()})" telegramNotificationService?.sendOrderFailureNotification( marketTitle = marketTitle, @@ -1442,7 +1465,7 @@ class AccountService( outcome = null, // 失败时可能没有 outcome price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET", size = sellQuantity.toString(), - errorMessage = errorMsg, // 只传递后端返回的 msg + errorMessage = errorMsg, // 只传递后端返回的错误信息 accountName = account.accountName, walletAddress = account.walletAddress, locale = locale @@ -1800,16 +1823,42 @@ class AccountService( for (transaction in accountTransactions) { val account = accounts[transaction.accountId] if (account != null) { - telegramNotificationService?.sendRedeemNotification( - accountName = account.accountName, - walletAddress = account.walletAddress, - transactionHash = transaction.transactionHash, - totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info -> - sum.add(info.value.toSafeBigDecimal()) - }.toPlainString(), - positions = transaction.positions, - locale = locale - ) + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}") + null + } + + // 计算该账户的赎回总价值 + val accountTotalValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info -> + sum.add(info.value.toSafeBigDecimal()) + } + + // 根据赎回价值选择不同的通知类型 + if (accountTotalValue.gt(BigDecimal.ZERO)) { + // 有收益:发送赎回成功通知 + telegramNotificationService?.sendRedeemNotification( + accountName = account.accountName, + walletAddress = account.walletAddress, + transactionHash = transaction.transactionHash, + totalRedeemedValue = accountTotalValue.toPlainString(), + positions = transaction.positions, + locale = locale, + availableBalance = availableBalance + ) + } else { + // 无收益(输的仓位):发送已结算无收益通知 + telegramNotificationService?.sendRedeemNoReturnNotification( + accountName = account.accountName, + walletAddress = account.walletAddress, + transactionHash = transaction.transactionHash, + positions = transaction.positions, + locale = locale, + availableBalance = availableBalance + ) + } } } } catch (e: Exception) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt index c47fe539..67dbf83e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt @@ -91,7 +91,12 @@ class BinanceKlineService { } }.toSet() val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet() - if (normalized == requiredMarketPrefixes.get()) return + + // 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开) + val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys } + + // 只有当集合相同且所有需要的连接都存在时才跳过 + if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return requiredMarketPrefixes.set(normalized) synchronized(subscriptionLock) { connectedWebSockets.keys.toList().forEach { wsKey -> diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt index 685bd933..a6307916 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt @@ -401,7 +401,7 @@ class PolymarketClobService( Result.failure(e) } } - + /** * 获取费率 * 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderPushService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderPushService.kt index 6ca9e1c4..77b6cca4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderPushService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderPushService.kt @@ -20,7 +20,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository import com.wrbug.polymarketbot.repository.LeaderRepository import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.service.common.MarketService +import com.wrbug.polymarketbot.util.div +import com.wrbug.polymarketbot.util.gt +import com.wrbug.polymarketbot.util.multi +import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.springframework.stereotype.Service +import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap /** @@ -426,20 +431,29 @@ class OrderPushService( // 获取市场信息(使用 MarketService,优先从数据库/缓存获取) val market = marketService.getMarket(conditionId ?: openOrder.market) - // 转换为 DTO + // 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched + val sizeMatched = openOrder.sizeMatched.toSafeBigDecimal() + val avgFilledPrice = if (sizeMatched.gt(BigDecimal.ZERO)) { + openOrder.originalSize.toSafeBigDecimal() + .multi(openOrder.price) + .div(sizeMatched, 18) + } else null + + // 转换为 DTO(展示数量用 size_matched) // 注意:createdAt 是 unix timestamp (Long),需要转换为字符串 OrderDetailDto( id = openOrder.id, market = openOrder.market, side = openOrder.side, price = openOrder.price, - size = openOrder.originalSize, // 使用 original_size - filled = openOrder.sizeMatched, // 使用 size_matched + size = openOrder.originalSize, + filled = openOrder.sizeMatched, // 已成交数量用 size_matched status = openOrder.status, createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串 marketName = market?.title, marketSlug = market?.slug, // 显示用的 slug - marketIcon = market?.icon + marketIcon = market?.icon, + avgFilledPrice = avgFilledPrice?.toPlainString() // 实际成交价 = original_size*price/size_matched ) }, onFailure = { e -> 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 2f31d630..46fe3fb2 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 @@ -7,8 +7,10 @@ import com.wrbug.polymarketbot.service.common.MarketService import com.wrbug.polymarketbot.service.system.TelegramNotificationService import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.CryptoUtils -import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.div +import com.wrbug.polymarketbot.util.gt import com.wrbug.polymarketbot.util.multi +import com.wrbug.polymarketbot.util.toSafeBigDecimal import kotlinx.coroutines.* import org.slf4j.LoggerFactory import org.springframework.boot.context.event.ApplicationReadyEvent @@ -38,7 +40,8 @@ class OrderStatusUpdateService( private val cryptoUtils: CryptoUtils, private val trackingService: CopyOrderTrackingService, private val marketService: MarketService, // 市场信息服务 - private val telegramNotificationService: TelegramNotificationService? + private val telegramNotificationService: TelegramNotificationService?, + private val blockchainService: com.wrbug.polymarketbot.service.common.BlockchainService ) : ApplicationContextAware { private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java) @@ -555,11 +558,13 @@ class OrderStatusUpdateService( logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice") - // 发送通知(使用实际价格) + // 发送通知(使用实际成交价) sendSellOrderNotification( record = updatedRecord, actualPrice = actualSellPrice.toString(), actualSize = record.totalMatchedQuantity.toString(), + avgFilledPrice = actualSellPrice.toString(), + filled = record.totalMatchedQuantity.toString(), account = account, copyTrading = copyTrading, clobApi = clobApi, @@ -588,11 +593,13 @@ class OrderStatusUpdateService( logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice") - // 发送通知 + // 发送通知(使用实际成交价) sendSellOrderNotification( record = updatedRecord, actualPrice = actualSellPrice.toString(), actualSize = record.totalMatchedQuantity.toString(), + avgFilledPrice = actualSellPrice.toString(), + filled = record.totalMatchedQuantity.toString(), account = account, copyTrading = copyTrading, clobApi = clobApi, @@ -844,12 +851,24 @@ class OrderStatusUpdateService( logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}") } - // 发送通知(使用实际数据) + // 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched + val sizeMatchedDec = orderDetail.sizeMatched.toSafeBigDecimal() + val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) { + orderDetail.originalSize.toSafeBigDecimal() + .multi(orderDetail.price) + .div(sizeMatchedDec, 18) + .toPlainString() + } else null + val filledSize = orderDetail.sizeMatched + + // 发送通知(使用实际数据,优先展示平均成交价) sendBuyOrderNotification( order = updatedOrder, actualPrice = actualPrice.toString(), actualSize = actualSize.toString(), actualOutcome = actualOutcome, + avgFilledPrice = avgFilledPriceStr, + filled = filledSize, account = account, copyTrading = copyTrading, clobApi = clobApi, @@ -876,6 +895,8 @@ class OrderStatusUpdateService( actualPrice: String? = null, actualSize: String? = null, actualOutcome: String? = null, + avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示) + filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算) account: Account? = null, copyTrading: CopyTrading? = null, clobApi: PolymarketClobApi? = null, @@ -930,14 +951,24 @@ class OrderStatusUpdateService( null } - // 发送通知 + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}") + null + } + + // 发送通知(优先使用平均成交价展示) telegramNotificationService.sendOrderSuccessNotification( orderId = order.buyOrderId, marketTitle = marketTitle, marketId = order.marketId, marketSlug = market?.eventSlug, // 跳转用的 slug side = "BUY", - price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格 + price = actualPrice ?: order.price.toString(), // 限价,无 avgFilledPrice 时展示 + avgFilledPrice = avgFilledPrice, + filled = filled, size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量 outcome = actualOutcome, // 使用实际 outcome accountName = finalAccount.accountName, @@ -950,7 +981,8 @@ class OrderStatusUpdateService( locale = locale, leaderName = leaderName, configName = configName, - orderTime = orderCreatedAt // 使用订单创建时间 + orderTime = orderCreatedAt, // 使用订单创建时间 + availableBalance = availableBalance ) logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}") @@ -969,6 +1001,8 @@ class OrderStatusUpdateService( actualPrice: String? = null, actualSize: String? = null, actualOutcome: String? = null, + avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示) + filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算) account: Account? = null, copyTrading: CopyTrading? = null, clobApi: PolymarketClobApi? = null, @@ -1023,14 +1057,24 @@ class OrderStatusUpdateService( null } - // 发送通知 + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}") + null + } + + // 发送通知(优先使用平均成交价展示) telegramNotificationService.sendOrderSuccessNotification( orderId = record.sellOrderId, marketTitle = marketTitle, marketId = record.marketId, marketSlug = market?.eventSlug, // 跳转用的 slug side = "SELL", - price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格 + price = actualPrice ?: record.sellPrice.toString(), // 限价,无 avgFilledPrice 时展示 + avgFilledPrice = avgFilledPrice, + filled = filled, size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量 outcome = actualOutcome, // 使用实际 outcome accountName = finalAccount.accountName, @@ -1043,7 +1087,8 @@ class OrderStatusUpdateService( locale = locale, leaderName = leaderName, configName = configName, - orderTime = orderCreatedAt // 使用订单创建时间 + orderTime = orderCreatedAt, // 使用订单创建时间 + availableBalance = availableBalance ) logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt index d713be18..5efa49b2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt @@ -147,7 +147,8 @@ class CryptoTailMonitorService( val account = accountRepository.findById(strategy.accountId).orElse(null) val nowSeconds = System.currentTimeMillis() / 1000 - val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds + val periodStartUnix = request.periodStartUnix + ?: ((nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds) // 获取市场信息 val slug = "${strategy.marketSlugPrefix}-$periodStartUnix" @@ -207,7 +208,9 @@ class CryptoTailMonitorService( tokenIdUp = tokenIds.getOrNull(0), tokenIdDown = tokenIds.getOrNull(1), currentTimestamp = System.currentTimeMillis(), - enabled = strategy.enabled + enabled = strategy.enabled, + amountMode = strategy.amountMode, + amountValue = strategy.amountValue.toPlainString() ) Result.success(response) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt index faba9f68..52ebedeb 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt @@ -6,6 +6,10 @@ import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.service.common.MarketService import com.wrbug.polymarketbot.service.system.TelegramNotificationService +import com.wrbug.polymarketbot.util.div +import com.wrbug.polymarketbot.util.gt +import com.wrbug.polymarketbot.util.multi +import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.CryptoUtils import com.wrbug.polymarketbot.util.RetrofitFactory import kotlinx.coroutines.CoroutineScope @@ -20,6 +24,7 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import jakarta.annotation.PreDestroy +import java.math.BigDecimal /** * 加密价差策略订单 TG 通知轮询服务(与跟单一致) @@ -128,6 +133,15 @@ class CryptoTailOrderNotificationPollingService( val market = marketService.getMarket(order.market) val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: order.market val orderTimeMs = if (order.createdAt < 1_000_000_000_000L) order.createdAt * 1000 else order.createdAt + // 实际成交价 = original_size * price / size_matched,数量用 size_matched + val sizeMatchedDec = order.sizeMatched.toSafeBigDecimal() + val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) { + order.originalSize.toSafeBigDecimal() + .multi(order.price) + .div(sizeMatchedDec, 18) + .toPlainString() + } else null + val filledSize = order.sizeMatched telegramNotificationService.sendCryptoTailOrderSuccessNotification( orderId = orderId, marketTitle = marketTitle, @@ -137,6 +151,8 @@ class CryptoTailOrderNotificationPollingService( outcome = order.outcome, price = order.price, size = order.originalSize, + avgFilledPrice = avgFilledPriceStr, + filled = filledSize, strategyName = strategy.name, accountName = account.accountName, walletAddress = account.walletAddress, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index 9119b1af..97478afc 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -3,6 +3,9 @@ package com.wrbug.polymarketbot.service.cryptotail import com.wrbug.polymarketbot.api.GammaEventBySlugResponse import com.wrbug.polymarketbot.api.NewOrderRequest import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse +import com.wrbug.polymarketbot.dto.ManualOrderDetails import com.wrbug.polymarketbot.entity.Account import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger @@ -411,7 +414,8 @@ class CryptoTailStrategyExecutionService( outcomeIndex, triggerPrice, amountUsdc, - orderRequest + orderRequest, + triggerType = "AUTO" ) return } @@ -427,7 +431,8 @@ class CryptoTailStrategyExecutionService( outcomeIndex: Int, triggerPrice: BigDecimal, amountUsdc: BigDecimal, - orderRequest: NewOrderRequest + orderRequest: NewOrderRequest, + triggerType: String = "AUTO" ) { var failReason: String? = null try { @@ -444,9 +449,10 @@ class CryptoTailStrategyExecutionService( amountUsdc, body.orderId, "success", - null + null, + triggerType = triggerType ) - logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType") return } failReason = body.errorMsg ?: "unknown" @@ -467,7 +473,8 @@ class CryptoTailStrategyExecutionService( amountUsdc, null, "fail", - failReason + failReason, + triggerType = triggerType ) logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason") } @@ -650,7 +657,8 @@ class CryptoTailStrategyExecutionService( amountUsdc: BigDecimal, orderId: String?, status: String, - failReason: String? + failReason: String?, + triggerType: String = "AUTO" ) { val record = CryptoTailStrategyTrigger( strategyId = strategy.id!!, @@ -661,11 +669,173 @@ class CryptoTailStrategyExecutionService( amountUsdc = amountUsdc, orderId = orderId, status = status, - failReason = failReason + failReason = failReason, + triggerType = triggerType ) triggerRepository.save(record) } + /** + * 手动下单:用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单 + */ + suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result<CryptoTailManualOrderResponse> { + return try { + val strategy = strategyRepository.findById(request.strategyId).orElse(null) + ?: return Result.failure(IllegalArgumentException("策略不存在")) + + val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1 + + if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) { + return Result.failure(IllegalArgumentException("outcomeIndex 越界")) + } + + val price = request.price.toSafeBigDecimal() + if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("价格必须在 0~1 之间")) + } + val priceRounded = price.setScale(4, RoundingMode.UP) + + val size = request.size.toSafeBigDecimal() + if (size < BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("数量不能少于 1")) + } + + val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP) + if (amountUsdc < BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC")) + } + + val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix) + mutex.withLock { + if (triggerRepository.findByStrategyIdAndPeriodStartUnix( + strategy.id!!, + request.periodStartUnix + ) != null + ) { + return@withLock Result.failure(IllegalArgumentException("当前周期已下单")) + } + + var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix) + if (ctx == null) { + ctx = ensurePeriodContext( + strategy, + request.periodStartUnix, + request.tokenIds, + request.marketTitle.ifBlank { null } + ) + } + if (ctx != null) { + val tokenId = request.tokenIds.getOrNull(outcomeIndex) + ?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界")) + + val priceStr = priceRounded.toPlainString() + val sizeStr = size.toPlainString() + val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" + + val signedOrder = orderSigningService.createAndSignOrder( + privateKey = ctx.decryptedPrivateKey, + makerAddress = ctx.account.proxyAddress, + tokenId = tokenId, + side = "BUY", + price = priceStr, + size = sizeStr, + signatureType = ctx.signatureType, + nonce = "0", + feeRateBps = feeRateBps, + expiration = "0" + ) + + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = ctx.account.apiKey!!, + orderType = "FAK", + deferExec = false + ) + + val orderResult = submitOrderForManualOrder( + ctx.clobApi, + strategy, + request.periodStartUnix, + request.marketTitle, + outcomeIndex, + priceRounded, + amountUsdc, + orderRequest + ) + + orderResult.fold( + onSuccess = { orderId -> + Result.success( + CryptoTailManualOrderResponse( + success = true, + orderId = orderId, + message = "下单成功", + orderDetails = ManualOrderDetails( + strategyId = strategy.id!!, + direction = request.direction, + price = priceStr, + size = sizeStr, + totalAmount = amountUsdc.toPlainString() + ) + ) + ) + }, + onFailure = { e -> + Result.failure(e) + } + ) + } else { + Result.failure(IllegalArgumentException("账户未配置或凭证不足")) + } + } + } catch (e: Exception) { + logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun submitOrderForManualOrder( + clobApi: PolymarketClobApi, + strategy: CryptoTailStrategy, + periodStartUnix: Long, + marketTitle: String?, + outcomeIndex: Int, + price: BigDecimal, + amountUsdc: BigDecimal, + orderRequest: NewOrderRequest + ): Result<String> { + return try { + val response = clobApi.createOrder(orderRequest) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + if (body.success && body.orderId != null) { + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + price, + amountUsdc, + body.orderId, + "success", + null, + triggerType = "MANUAL" + ) + logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + Result.success(body.orderId) + } else { + Result.failure(Exception(body.errorMsg ?: "unknown")) + } + } else { + val errorBody = response.errorBody()?.string().orEmpty() + Result.failure(Exception(errorBody.ifEmpty { "请求失败" })) + } + } catch (e: Exception) { + logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e) + Result.failure(e) + } + } + @PreDestroy fun destroy() { // 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt index aaac15fe..c78df336 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -9,6 +9,7 @@ import com.wrbug.polymarketbot.enums.SpreadDirection import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent +import com.wrbug.polymarketbot.util.gt import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher @@ -220,6 +221,61 @@ class CryptoTailStrategyService( } } + fun getPnlCurve(request: CryptoTailPnlCurveRequest): Result<CryptoTailPnlCurveResponse> { + return try { + val strategy = strategyRepository.findById(request.strategyId).orElse(null) + ?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey)) + val start = request.startDate ?: 0L + val end = request.endDate ?: Long.MAX_VALUE + val triggers = triggerRepository.findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc( + request.strategyId, start, end + ) + var cumulative = BigDecimal.ZERO + var peak = BigDecimal.ZERO + var maxDrawdown = BigDecimal.ZERO + var winCountInRange = 0L + val curveData = triggers.map { t -> + val pnl = t.realizedPnl ?: BigDecimal.ZERO + cumulative = cumulative.add(pnl) + if (cumulative.gt(peak)) peak = cumulative + val drawdown = peak.subtract(cumulative) + if (drawdown.gt(maxDrawdown)) maxDrawdown = drawdown + if (t.winnerOutcomeIndex != null && t.outcomeIndex == t.winnerOutcomeIndex) winCountInRange++ + val ts = t.settledAt ?: t.createdAt + CryptoTailPnlCurvePoint( + timestamp = ts, + cumulativePnl = cumulative.toPlainString(), + pointPnl = pnl.toPlainString(), + settledCount = 0L + ) + }.mapIndexed { index, p -> + p.copy(settledCount = (index + 1).toLong()) + } + val totalPnl = if (curveData.isEmpty()) BigDecimal.ZERO else curveData.last().cumulativePnl.toSafeBigDecimal() + val settledCountInRange = curveData.size.toLong() + val winRateStr = if (settledCountInRange > 0L) { + BigDecimal(winCountInRange).divide(BigDecimal(settledCountInRange), 4, java.math.RoundingMode.HALF_UP).toPlainString() + } else null + Result.success( + CryptoTailPnlCurveResponse( + strategyId = request.strategyId, + strategyName = strategy.name ?: strategy.marketSlugPrefix, + totalRealizedPnl = totalPnl.toPlainString(), + settledCount = settledCountInRange, + winCount = winCountInRange, + winRate = winRateStr, + maxDrawdown = if (maxDrawdown.compareTo(BigDecimal.ZERO) > 0) maxDrawdown.toPlainString() else null, + curveData = curveData + ) + ) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } catch (e: Exception) { + logger.error("查询收益曲线失败: ${e.message}", e) + Result.failure(e) + } + } + fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> { return try { val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100)) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt new file mode 100644 index 00000000..28f95f14 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt @@ -0,0 +1,457 @@ +package com.wrbug.polymarketbot.service.system + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.NotificationTemplate +import com.wrbug.polymarketbot.repository.NotificationTemplateRepository +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 消息模板服务 + * 负责管理消息模板、渲染模板、提供变量信息 + */ +@Service +class NotificationTemplateService( + private val templateRepository: NotificationTemplateRepository, + @Lazy private val telegramNotificationService: TelegramNotificationService +) { + private val logger = LoggerFactory.getLogger(NotificationTemplateService::class.java) + + companion object { + // 模板类型定义 + val TEMPLATE_TYPES = mapOf( + "ORDER_SUCCESS" to TemplateTypeInfoDto( + type = "ORDER_SUCCESS", + name = "订单成功通知", + description = "订单创建成功时发送的通知" + ), + "ORDER_FAILED" to TemplateTypeInfoDto( + type = "ORDER_FAILED", + name = "订单失败通知", + description = "订单创建失败时发送的通知" + ), + "ORDER_FILTERED" to TemplateTypeInfoDto( + type = "ORDER_FILTERED", + name = "订单过滤通知", + description = "订单被风控过滤时发送的通知" + ), + "CRYPTO_TAIL_SUCCESS" to TemplateTypeInfoDto( + type = "CRYPTO_TAIL_SUCCESS", + name = "加密价差策略成功通知", + description = "加密价差策略下单成功时发送的通知" + ), + "REDEEM_SUCCESS" to TemplateTypeInfoDto( + type = "REDEEM_SUCCESS", + name = "仓位赎回成功通知", + description = "仓位赎回成功时发送的通知" + ), + "REDEEM_NO_RETURN" to TemplateTypeInfoDto( + type = "REDEEM_NO_RETURN", + name = "仓位结算(无收益)通知", + description = "仓位结算但无收益时发送的通知" + ) + ) + + // 变量分类 + val VARIABLE_CATEGORIES = listOf( + TemplateVariableCategoryDto("common", 0), + TemplateVariableCategoryDto("order", 10), + TemplateVariableCategoryDto("copy_trading", 20), + TemplateVariableCategoryDto("redeem", 30), + TemplateVariableCategoryDto("error", 40), + TemplateVariableCategoryDto("filter", 50), + TemplateVariableCategoryDto("strategy", 60) + ) + + // 各模板类型可用的变量 + val TEMPLATE_VARIABLES = mapOf( + "ORDER_SUCCESS" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "common", 1), + TemplateVariableDto("wallet_address", "common", 2), + TemplateVariableDto("time", "common", 3), + // 订单变量 + TemplateVariableDto("order_id", "order", 10), + TemplateVariableDto("market_title", "order", 11), + TemplateVariableDto("market_link", "order", 12), + TemplateVariableDto("side", "order", 13), + TemplateVariableDto("outcome", "order", 14), + TemplateVariableDto("price", "order", 15), + TemplateVariableDto("quantity", "order", 16), + TemplateVariableDto("amount", "order", 17), + TemplateVariableDto("available_balance", "order", 18), + // 跟单变量 + TemplateVariableDto("leader_name", "copy_trading", 21), + TemplateVariableDto("config_name", "copy_trading", 22) + ), + "ORDER_FAILED" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "common", 1), + TemplateVariableDto("wallet_address", "common", 2), + TemplateVariableDto("time", "common", 3), + // 订单变量 + TemplateVariableDto("market_title", "order", 10), + TemplateVariableDto("market_link", "order", 11), + TemplateVariableDto("side", "order", 12), + TemplateVariableDto("outcome", "order", 13), + TemplateVariableDto("price", "order", 14), + TemplateVariableDto("quantity", "order", 15), + TemplateVariableDto("amount", "order", 16), + // 错误变量 + TemplateVariableDto("error_message", "error", 20) + ), + "ORDER_FILTERED" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "common", 1), + TemplateVariableDto("wallet_address", "common", 2), + TemplateVariableDto("time", "common", 3), + // 订单变量 + TemplateVariableDto("market_title", "order", 10), + TemplateVariableDto("market_link", "order", 11), + TemplateVariableDto("side", "order", 12), + TemplateVariableDto("outcome", "order", 13), + TemplateVariableDto("price", "order", 14), + TemplateVariableDto("quantity", "order", 15), + TemplateVariableDto("amount", "order", 16), + // 过滤变量 + TemplateVariableDto("filter_type", "filter", 20), + TemplateVariableDto("filter_reason", "filter", 21) + ), + "CRYPTO_TAIL_SUCCESS" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "common", 1), + TemplateVariableDto("wallet_address", "common", 2), + TemplateVariableDto("time", "common", 3), + // 订单变量 + TemplateVariableDto("order_id", "order", 10), + TemplateVariableDto("market_title", "order", 11), + TemplateVariableDto("market_link", "order", 12), + TemplateVariableDto("side", "order", 13), + TemplateVariableDto("outcome", "order", 14), + TemplateVariableDto("price", "order", 15), + TemplateVariableDto("quantity", "order", 16), + TemplateVariableDto("amount", "order", 17), + // 策略变量 + TemplateVariableDto("strategy_name", "strategy", 20) + ), + "REDEEM_SUCCESS" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "common", 1), + TemplateVariableDto("wallet_address", "common", 2), + TemplateVariableDto("time", "common", 3), + // 赎回变量 + TemplateVariableDto("transaction_hash", "redeem", 10), + TemplateVariableDto("total_value", "redeem", 11), + TemplateVariableDto("available_balance", "redeem", 12) + ), + "REDEEM_NO_RETURN" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "common", 1), + TemplateVariableDto("wallet_address", "common", 2), + TemplateVariableDto("time", "common", 3), + // 赎回变量 + TemplateVariableDto("transaction_hash", "redeem", 10), + TemplateVariableDto("available_balance", "redeem", 11) + ) + ) + + // 默认模板 + val DEFAULT_TEMPLATES = mapOf( + "ORDER_SUCCESS" to """ +🚀 <b>订单创建成功</b> + +📊 <b>订单信息:</b> +• 订单ID: <code>{{order_id}}</code> +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} +• 可用余额: <code>{{available_balance}}</code> USDC + +⏰ 时间: <code>{{time}}</code> + """.trimIndent(), + "ORDER_FAILED" to """ +❌ <b>订单创建失败</b> + +📊 <b>订单信息:</b> +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} + +⚠️ <b>错误信息:</b> +<code>{{error_message}}</code> + +⏰ 时间: <code>{{time}}</code> + """.trimIndent(), + "ORDER_FILTERED" to """ +🚫 <b>订单被过滤</b> + +📊 <b>订单信息:</b> +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} + +⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code> + +📝 <b>过滤原因:</b> +<code>{{filter_reason}}</code> + +⏰ 时间: <code>{{time}}</code> + """.trimIndent(), + "CRYPTO_TAIL_SUCCESS" to """ +🚀 <b>加密价差策略下单成功</b> + +📊 <b>订单信息:</b> +• 订单ID: <code>{{order_id}}</code> +• 策略: {{strategy_name}} +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} + +⏰ 时间: <code>{{time}}</code> + """.trimIndent(), + "REDEEM_SUCCESS" to """ +💸 <b>仓位赎回成功</b> + +📊 <b>赎回信息:</b> +• 账户: {{account_name}} +• 交易哈希: <code>{{transaction_hash}}</code> +• 赎回总价值: <code>{{total_value}}</code> USDC +• 可用余额: <code>{{available_balance}}</code> USDC + +⏰ 时间: <code>{{time}}</code> + """.trimIndent(), + "REDEEM_NO_RETURN" to """ +📋 <b>仓位已结算(无收益)</b> + +📊 <b>结算信息:</b> +<i>市场已结算,您的预测未命中,赎回价值为 0。</i> + +• 账户: {{account_name}} +• 交易哈希: <code>{{transaction_hash}}</code> +• 可用余额: <code>{{available_balance}}</code> USDC + +⏰ 时间: <code>{{time}}</code> + """.trimIndent() + ) + } + + /** + * 获取所有模板类型 + */ + fun getTemplateTypes(): List<TemplateTypeInfoDto> { + return TEMPLATE_TYPES.values.toList() + } + + /** + * 获取所有模板列表 + */ + fun getAllTemplates(): List<NotificationTemplateDto> { + return templateRepository.findAll().map { it.toDto() } + } + + /** + * 获取单个模板 + */ + fun getTemplate(templateType: String): NotificationTemplateDto? { + return templateRepository.findByTemplateType(templateType)?.toDto() + ?: DEFAULT_TEMPLATES[templateType]?.let { + NotificationTemplateDto( + templateType = templateType, + templateContent = it, + isDefault = true + ) + } + } + + /** + * 获取模板可用变量 + */ + fun getTemplateVariables(templateType: String): TemplateVariablesResponse? { + if (!TEMPLATE_TYPES.containsKey(templateType)) return null + val variables = TEMPLATE_VARIABLES[templateType] ?: emptyList() + + // 获取使用的分类 + val usedCategories = variables.map { it.category }.toSet() + val categories = VARIABLE_CATEGORIES.filter { usedCategories.contains(it.key) } + + return TemplateVariablesResponse( + templateType = templateType, + categories = categories, + variables = variables + ) + } + + /** + * 更新模板 + */ + @Transactional + fun updateTemplate(templateType: String, content: String): NotificationTemplateDto { + val template = templateRepository.findByTemplateType(templateType) + val now = System.currentTimeMillis() + + return if (template != null) { + template.templateContent = content + template.isDefault = false + template.updatedAt = now + templateRepository.save(template).toDto() + } else { + val newTemplate = NotificationTemplate( + templateType = templateType, + templateContent = content, + isDefault = false, + createdAt = now, + updatedAt = now + ) + templateRepository.save(newTemplate).toDto() + } + } + + /** + * 重置模板为默认 + */ + @Transactional + fun resetTemplate(templateType: String): NotificationTemplateDto? { + val defaultContent = DEFAULT_TEMPLATES[templateType] ?: return null + val template = templateRepository.findByTemplateType(templateType) + val now = System.currentTimeMillis() + + return if (template != null) { + template.templateContent = defaultContent + template.isDefault = true + template.updatedAt = now + templateRepository.save(template).toDto() + } else { + val newTemplate = NotificationTemplate( + templateType = templateType, + templateContent = defaultContent, + isDefault = true, + createdAt = now, + updatedAt = now + ) + templateRepository.save(newTemplate).toDto() + } + } + + /** + * 渲染模板(按类型取模板内容后替换变量) + * 优化:先解析模版中需要的变量,只替换这些变量,未提供的变量使用 "-" 占位 + */ + fun renderTemplate(templateType: String, variables: Map<String, String>): String { + val template = getTemplate(templateType) + val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: "" + return renderTemplateContent(content, variables) + } + + /** + * 对给定模板内容做变量替换(不查库) + * 优化:先解析模版中的变量占位符,只替换这些变量,未提供的变量使用 "-" 占位 + */ + fun renderTemplateContent(content: String, variables: Map<String, String>): String { + // 先解析模版中需要的变量 + val requiredVariables = extractTemplateVariables(content) + + var result = content + // 只替换模版中实际使用的变量 + requiredVariables.forEach { varName -> + val value = variables[varName] + result = result.replace("{{$varName}}", value ?: "-") + } + return result + } + + /** + * 解析模版中使用的变量名 + * @return 变量名列表(去重) + */ + private fun extractTemplateVariables(content: String): Set<String> { + val regex = Regex("\\{\\{([^}]+)}}") + return regex.findAll(content) + .map { it.groupValues[1].trim() } + .toSet() + } + + /** + * 根据模版需要的变量过滤输入变量 + * 只保留模版中实际使用的变量,避免不必要的数据获取 + */ + fun filterVariablesForTemplate(templateType: String, variables: Map<String, String>): Map<String, String> { + val template = getTemplate(templateType) + val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: return emptyMap() + val requiredVariables = extractTemplateVariables(content) + return variables.filterKeys { it in requiredVariables } + } + + /** + * 发送测试消息 + */ + suspend fun sendTestMessage(templateType: String, content: String? = null): Boolean { + val templateContent = content ?: getTemplate(templateType)?.templateContent ?: return false + val testVariables = generateTestVariables(templateType) + val message = renderTemplateContent(templateContent, testVariables) + return try { + telegramNotificationService.sendMessage(message) + true + } catch (e: Exception) { + logger.error("发送测试消息失败: ${e.message}", e) + false + } + } + + /** + * 生成测试变量数据 + */ + private fun generateTestVariables(templateType: String): Map<String, String> { + return mapOf( + "account_name" to "测试账户", + "wallet_address" to "0x1234...5678", + "time" to "2024-01-15 12:30:00", + "order_id" to "12345678", + "market_title" to "测试市场标题", + "market_link" to "https://polymarket.com/event/test", + "side" to "买入", + "outcome" to "YES", + "price" to "0.55", + "quantity" to "100", + "amount" to "55.00", + "available_balance" to "1000.00", + "leader_name" to "测试Leader", + "config_name" to "测试配置", + "error_message" to "余额不足", + "filter_type" to "价差过大", + "filter_reason" to "当前市场价差为 5%,超过设定的 3% 限制", + "strategy_name" to "BTC价差策略", + "transaction_hash" to "0xabcd...efgh", + "total_value" to "100.00" + ) + } + + /** + * Entity 转 DTO + */ + private fun NotificationTemplate.toDto() = NotificationTemplateDto( + id = id, + templateType = templateType, + templateContent = templateContent, + isDefault = isDefault, + createdAt = createdAt, + updatedAt = updatedAt + ) +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index 49dba410..c473a88b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit @Service class TelegramNotificationService( private val notificationConfigService: NotificationConfigService, + private val notificationTemplateService: NotificationTemplateService, private val objectMapper: ObjectMapper, private val messageSource: MessageSource ) { @@ -85,7 +86,9 @@ class TelegramNotificationService( marketId: String? = null, marketSlug: String? = null, side: String, - price: String? = null, // 订单价格(可选,如果提供则直接使用) + price: String? = null, // 订单限价(可选) + avgFilledPrice: String? = null, // 平均成交价(可选,有成交时优先展示) + filled: String? = null, // 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算) size: String? = null, // 订单数量(可选,如果提供则直接使用) outcome: String? = null, // 市场方向(可选,如果提供则直接使用) accountName: String? = null, @@ -98,7 +101,8 @@ class TelegramNotificationService( locale: java.util.Locale? = null, leaderName: String? = null, // Leader 名称(备注) configName: String? = null, // 跟单配置名 - orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示 + orderTime: Long? = null, // 订单创建时间(毫秒时间戳),用于通知中的时间显示 + availableBalance: String? = null // 可用余额(可选) ) { // 1. 如果提供了 orderId,检查是否已发送过通知(去重) if (orderId != null) { @@ -128,14 +132,21 @@ class TelegramNotificationService( java.util.Locale("zh", "CN") // 默认简体中文 } - // 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取 - var actualPrice: String? = price + // 优先使用平均成交价(实际成交价),其次传入的限价,若未提供则从订单详情获取 + var actualPrice: String? = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price var actualSize: String? = size var actualSide: String = side var actualOutcome: String? = outcome + + // 有平均成交价时,已成交数量优先用 filled,用于金额计算 + val sizeForAmount: String? = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) { + filled + } else { + null + } - // 如果价格或数量未提供,尝试从订单详情获取 - if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) { + // 如果价格、数量或市场方向未提供,尝试从订单详情获取 + if ((actualPrice == null || actualSize == null || actualOutcome == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) { try { val orderResponse = clobApi.getOrder(orderId) if (orderResponse.isSuccessful) { @@ -147,7 +158,8 @@ class TelegramNotificationService( if (actualSize == null) { actualSize = order.originalSize // 使用 originalSize 作为订单数量 } - actualSide = order.side // 使用订单详情中的 side + // 注意:不覆盖 side,因为传入的 side(BUY/SELL)是正确的 + // actualSide = order.side // 不要使用订单详情中的 side,因为它可能不准确 if (actualOutcome == null) { actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向) } @@ -165,19 +177,28 @@ class TelegramNotificationService( // 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留) val finalPrice = actualPrice ?: "0" - val finalSize = actualSize ?: "0" + // 有实际成交价时展示数量用 size_matched(filled),否则用订单数量(original_size) + val finalSize = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) { + filled + } else { + actualSize ?: "0" + } + // 金额计算:有实际成交价和已成交数量时用二者乘积,否则用展示价格×订单数量 + val sizeForCalc = sizeForAmount?.takeIf { it.isNotBlank() } ?: finalSize // 计算订单金额 = price × size(USDC) val amount = try { val priceDecimal = finalPrice.toSafeBigDecimal() - val sizeDecimal = finalSize.toSafeBigDecimal() + val sizeDecimal = sizeForCalc.toSafeBigDecimal() priceDecimal.multiply(sizeDecimal).toString() } catch (e: Exception) { logger.warn("计算订单金额失败: ${e.message}", e) null } - val message = buildOrderSuccessMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildOrderSuccessVariables( orderId = orderId, marketTitle = marketTitle, marketId = marketId, @@ -192,8 +213,12 @@ class TelegramNotificationService( locale = currentLocale, leaderName = leaderName, configName = configName, - orderTime = orderTime + orderTime = orderTime, + availableBalance = availableBalance, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed ) + val message = notificationTemplateService.renderTemplate("ORDER_SUCCESS", vars) sendMessage(message) } @@ -232,7 +257,9 @@ class TelegramNotificationService( null } - val message = buildOrderFailureMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildOrderFailureVariables( marketTitle = marketTitle, marketId = marketId, marketSlug = marketSlug, @@ -244,11 +271,65 @@ class TelegramNotificationService( errorMessage = errorMessage, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale + locale = currentLocale, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed ) + val message = notificationTemplateService.renderTemplate("ORDER_FAILED", vars) sendMessage(message) } + /** + * 构建订单失败通知的变量 Map + */ + private fun buildOrderFailureVariables( + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + errorMessage: String, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + unknownAccount: String, + calculateFailed: String + ): Map<String, String> { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = amount?.let { am -> + try { + val amountDecimal = am.toSafeBigDecimal() + (if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { am } + } ?: calculateFailed + val shortError = if (errorMessage.length > 500) errorMessage.substring(0, 500) + "..." else errorMessage + return mapOf( + "market_title" to marketTitle.replace("<", "<").replace(">", ">"), + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""), + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "error_message" to shortError.replace("<", "<").replace(">", ">"), + "time" to DateUtils.formatDateTime() + ) + } + /** * 发送订单被过滤通知 * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) @@ -285,7 +366,9 @@ class TelegramNotificationService( null } - val message = buildOrderFilteredMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildOrderFilteredVariables( marketTitle = marketTitle, marketId = marketId, marketSlug = marketSlug, @@ -298,13 +381,75 @@ class TelegramNotificationService( filterType = filterType, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale + locale = currentLocale, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed ) + val message = notificationTemplateService.renderTemplate("ORDER_FILTERED", vars) sendMessage(message) } + private fun buildOrderFilteredVariables( + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + filterReason: String, + filterType: String, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + unknownAccount: String, + calculateFailed: String + ): Map<String, String> { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val filterTypeDisplay = when (filterType.uppercase()) { + "ORDER_DEPTH" -> messageSource.getMessage("notification.filter.type.order_depth", null, "订单深度不足", locale).orEmpty().ifEmpty { "订单深度不足" } + "SPREAD" -> messageSource.getMessage("notification.filter.type.spread", null, "价差过大", locale).orEmpty().ifEmpty { "价差过大" } + "ORDERBOOK_DEPTH" -> messageSource.getMessage("notification.filter.type.orderbook_depth", null, "订单簿深度不足", locale).orEmpty().ifEmpty { "订单簿深度不足" } + "PRICE_VALIDITY" -> messageSource.getMessage("notification.filter.type.price_validity", null, "价格不合理", locale).orEmpty().ifEmpty { "价格不合理" } + "MARKET_STATUS" -> messageSource.getMessage("notification.filter.type.market_status", null, "市场状态不可交易", locale).orEmpty().ifEmpty { "市场状态不可交易" } + else -> filterType + } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = amount?.let { am -> + try { + (am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString()) + } catch (e: Exception) { am } + } ?: calculateFailed + return mapOf( + "market_title" to marketTitle.replace("<", "<").replace(">", ">"), + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""), + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "filter_type" to filterTypeDisplay, + "filter_reason" to filterReason.replace("<", "<").replace(">", ">"), + "time" to DateUtils.formatDateTime() + ) + } + /** * 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用) + * @param price 订单限价 + * @param avgFilledPrice 平均成交价(可选,有成交时优先展示) + * @param filled 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算) */ suspend fun sendCryptoTailOrderSuccessNotification( orderId: String?, @@ -315,6 +460,8 @@ class TelegramNotificationService( outcome: String? = null, price: String, size: String, + avgFilledPrice: String? = null, + filled: String? = null, strategyName: String? = null, accountName: String? = null, walletAddress: String? = null, @@ -339,33 +486,95 @@ class TelegramNotificationService( logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e) java.util.Locale("zh", "CN") } + val displayPrice = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price + val hasAvgFilled = avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank() + val sizeForAmount = if (hasAvgFilled) filled else size + val quantityDisplay = if (hasAvgFilled) filled else size // 有实际成交价时展示数量用 size_matched val amount = try { - val priceDecimal = price.toSafeBigDecimal() - val sizeDecimal = size.toSafeBigDecimal() + val priceDecimal = displayPrice.toSafeBigDecimal() + val sizeDecimal = sizeForAmount.toSafeBigDecimal() priceDecimal.multiply(sizeDecimal).toString() } catch (e: Exception) { logger.warn("计算订单金额失败: ${e.message}", e) null } - val message = buildCryptoTailOrderSuccessMessage( + val unknown = messageSource.getMessage("common.unknown", null, "未知", currentLocale).orEmpty().ifEmpty { "未知" } + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildCryptoTailOrderSuccessVariables( orderId = orderId, marketTitle = marketTitle, marketId = marketId, marketSlug = marketSlug, side = side, outcome = outcome, - price = price, - size = size, + price = displayPrice, + size = quantityDisplay.orEmpty(), amount = amount, strategyName = strategyName, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale, - orderTime = orderTime + orderTime = orderTime, + unknown = unknown, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed, + locale = currentLocale ) + val message = notificationTemplateService.renderTemplate("CRYPTO_TAIL_SUCCESS", vars) sendMessage(message) } + private fun buildCryptoTailOrderSuccessVariables( + orderId: String?, + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + strategyName: String?, + accountName: String?, + walletAddress: String?, + orderTime: Long?, + unknown: String, + unknownAccount: String, + calculateFailed: String, + locale: java.util.Locale + ): Map<String, String> { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime() + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = amount?.let { am -> + try { + (am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString()) + } catch (e: Exception) { am } + } ?: calculateFailed + return mapOf( + "order_id" to (orderId ?: unknown), + "market_title" to marketTitle.replace("<", "<").replace(">", ">"), + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""), + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "strategy_name" to (strategyName?.takeIf { it.isNotBlank() } ?: unknown), + "time" to time + ) + } + /** * 构建订单被过滤消息 */ @@ -748,6 +957,76 @@ class TelegramNotificationService( } } + /** + * 构建订单成功通知的变量 Map(供模板渲染) + */ + private fun buildOrderSuccessVariables( + orderId: String?, + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + leaderName: String?, + configName: String?, + orderTime: Long?, + availableBalance: String?, + unknownAccount: String, + calculateFailed: String + ): Map<String, String> { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val unknown = messageSource.getMessage("common.unknown", null, "未知", locale).orEmpty().ifEmpty { "未知" } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime() + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = when { + amount != null -> try { + val amountDecimal = amount.toSafeBigDecimal() + val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros() + formatted.toPlainString() + } catch (e: Exception) { amount ?: calculateFailed } + else -> calculateFailed + } + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else balanceDecimal.stripTrailingZeros() + formatted.toPlainString() + } catch (e: Exception) { availableBalance ?: "" } + } else { "" } + val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">") + val escapedOutcome = outcome?.replace("<", "<")?.replace(">", ">") ?: "" + return mapOf( + "order_id" to (orderId ?: unknown), + "market_title" to escapedMarketTitle, + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to escapedOutcome, + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "available_balance" to availableBalanceDisplay, + "leader_name" to (leaderName ?: ""), + "config_name" to (configName ?: ""), + "time" to time + ) + } + /** * 构建订单成功消息 */ @@ -766,7 +1045,8 @@ class TelegramNotificationService( locale: java.util.Locale, leaderName: String? = null, // Leader 名称(备注) configName: String? = null, // 跟单配置名 - orderTime: Long? = null // 订单创建时间(毫秒时间戳) + orderTime: Long? = null, // 订单创建时间(毫秒时间戳) + availableBalance: String? = null // 可用余额 ): String { // 获取多语言文本 @@ -781,6 +1061,7 @@ class TelegramNotificationService( val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale) val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale) val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val availableBalanceLabel = messageSource.getMessage("notification.order.available_balance", null, "可用余额", locale) val unknown = messageSource.getMessage("common.unknown", null, "未知", locale) val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale) @@ -879,6 +1160,23 @@ class TelegramNotificationService( val priceDisplay = formatPrice(price) val sizeDisplay = formatQuantity(size) + // 格式化可用余额 + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) { + balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + balanceDecimal.stripTrailingZeros() + } + "\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" + } catch (e: Exception) { + "\n• $availableBalanceLabel: <code>$availableBalance</code> USDC" + } + } else { + "" + } + return """$icon <b>$orderCreatedSuccess</b> 📊 <b>$orderInfo:</b> @@ -888,7 +1186,7 @@ class TelegramNotificationService( • $priceLabel: <code>$priceDisplay</code> • $quantityLabel: <code>$sizeDisplay</code> shares • $amountLabel: <code>$amountDisplay</code> USDC -• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo +• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay ⏰ $timeLabel: <code>$time</code>""" } @@ -1095,6 +1393,7 @@ class TelegramNotificationService( /** * 发送仓位赎回通知 * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) + * @param availableBalance 可用余额(可选) */ suspend fun sendRedeemNotification( accountName: String?, @@ -1102,7 +1401,8 @@ class TelegramNotificationService( transactionHash: String, totalRedeemedValue: String, positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, - locale: java.util.Locale? = null + locale: java.util.Locale? = null, + availableBalance: String? = null ) { // 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取) val currentLocale = locale ?: try { @@ -1112,16 +1412,46 @@ class TelegramNotificationService( java.util.Locale("zh", "CN") // 默认简体中文 } - val message = buildRedeemMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户" + val vars = buildRedeemSuccessVariables( accountName = accountName, walletAddress = walletAddress, transactionHash = transactionHash, totalRedeemedValue = totalRedeemedValue, - positions = positions, - locale = currentLocale + availableBalance = availableBalance, + unknownAccount = unknownAccount ) + val message = notificationTemplateService.renderTemplate("REDEEM_SUCCESS", vars) sendMessage(message) } + + private fun buildRedeemSuccessVariables( + accountName: String?, + walletAddress: String?, + transactionHash: String, + totalRedeemedValue: String, + availableBalance: String?, + unknownAccount: String + ): Map<String, String> { + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val totalValueDisplay = try { + val d = totalRedeemedValue.toSafeBigDecimal() + (if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { totalRedeemedValue } + val availableBalanceDisplay = availableBalance?.let { ab -> + try { + val d = ab.toSafeBigDecimal() + (if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { ab } + } ?: "" + return mapOf( + "account_name" to accountInfo, + "transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"), + "total_value" to totalValueDisplay, + "available_balance" to availableBalanceDisplay, + "time" to DateUtils.formatDateTime() + ) + } /** * 构建仓位赎回消息 @@ -1132,7 +1462,8 @@ class TelegramNotificationService( transactionHash: String, totalRedeemedValue: String, positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, - locale: java.util.Locale + locale: java.util.Locale, + availableBalance: String? = null ): String { // 获取多语言文本 val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale) @@ -1145,6 +1476,7 @@ class TelegramNotificationService( val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale) val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale) val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale) val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" // 构建账户信息(格式:账户名(钱包地址)) @@ -1186,19 +1518,152 @@ class TelegramNotificationService( " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC" } + // 格式化可用余额 + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) { + balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + balanceDecimal.stripTrailingZeros() + } + "\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" + } catch (e: Exception) { + "\n• $availableBalanceLabel: <code>$availableBalance</code> USDC" + } + } else { + "" + } + return """💸 <b>$redeemSuccess</b> 📊 <b>$redeemInfo:</b> • $accountLabel: $escapedAccountInfo • $transactionHashLabel: <code>$escapedTxHash</code> -• $totalValueLabel: <code>$totalValueDisplay</code> USDC +• $totalValueLabel: <code>$totalValueDisplay</code> USDC$availableBalanceDisplay 📦 <b>$positionsLabel:</b> $positionsText ⏰ $timeLabel: <code>$time</code>""" } - + + /** + * 发送仓位已结算(无收益)通知 + * 用于输的仓位,赎回价值为 0 的情况 + */ + suspend fun sendRedeemNoReturnNotification( + accountName: String?, + walletAddress: String?, + transactionHash: String, + positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, + locale: java.util.Locale? = null, + availableBalance: String? = null + ) { + val currentLocale = locale ?: try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e) + java.util.Locale("zh", "CN") + } + + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户" + val vars = buildRedeemNoReturnVariables( + accountName = accountName, + walletAddress = walletAddress, + transactionHash = transactionHash, + availableBalance = availableBalance, + unknownAccount = unknownAccount + ) + val message = notificationTemplateService.renderTemplate("REDEEM_NO_RETURN", vars) + sendMessage(message) + } + + private fun buildRedeemNoReturnVariables( + accountName: String?, + walletAddress: String?, + transactionHash: String, + availableBalance: String?, + unknownAccount: String + ): Map<String, String> { + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val availableBalanceDisplay = availableBalance?.let { ab -> + try { + val d = ab.toSafeBigDecimal() + (if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { ab } + } ?: "" + return mapOf( + "account_name" to accountInfo, + "transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"), + "available_balance" to availableBalanceDisplay, + "time" to DateUtils.formatDateTime() + ) + } + + /** + * 构建仓位已结算(无收益)消息 + */ + private fun buildRedeemNoReturnMessage( + accountName: String?, + walletAddress: String?, + transactionHash: String, + positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, + locale: java.util.Locale, + availableBalance: String? = null + ): String { + val noReturnTitle = messageSource.getMessage("notification.redeem.no_return.title", null, "仓位已结算(无收益)", locale) + val noReturnInfo = messageSource.getMessage("notification.redeem.no_return.info", null, "结算信息", locale) + val noReturnMessage = messageSource.getMessage("notification.redeem.no_return.message", null, "市场已结算,您的预测未命中,赎回价值为 0。", locale) + val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale) + val transactionHashLabel = messageSource.getMessage("notification.redeem.transaction_hash", null, "交易哈希", locale) + val positionsLabel = messageSource.getMessage("notification.redeem.no_return.positions", null, "结算仓位", locale) + val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale) + val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" + + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = DateUtils.formatDateTime() + + val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">") + val escapedTxHash = transactionHash.replace("<", "<").replace(">", ">") + + val positionsText = positions.joinToString("\n") { position -> + val quantityDisplay = formatQuantity(position.quantity) + " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares" + } + + // 格式化可用余额 + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) { + balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + balanceDecimal.stripTrailingZeros() + } + "\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" + } catch (e: Exception) { + "\n• $availableBalanceLabel: <code>$availableBalance</code> USDC" + } + } else { + "" + } + + return """📋 <b>$noReturnTitle</b> + +📊 <b>$noReturnInfo:</b> +<i>$noReturnMessage</i> + +• $accountLabel: $escapedAccountInfo +• $transactionHashLabel: <code>$escapedTxHash</code>$availableBalanceDisplay + +📦 <b>$positionsLabel:</b> +$positionsText + +⏰ $timeLabel: <code>$time</code>""" + } + /** * 脱敏显示地址(只显示前6位和后4位) */ diff --git a/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql b/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql new file mode 100644 index 00000000..60df806a --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql @@ -0,0 +1,5 @@ +-- 添加触发类型字段到加密价差策略触发记录表 +-- AUTO: 自动下单触发 +-- MANUAL: 手动下单触发 +ALTER TABLE crypto_tail_strategy_trigger +ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)'; diff --git a/backend/src/main/resources/db/migration/V40__create_notification_templates.sql b/backend/src/main/resources/db/migration/V40__create_notification_templates.sql new file mode 100644 index 00000000..44389440 --- /dev/null +++ b/backend/src/main/resources/db/migration/V40__create_notification_templates.sql @@ -0,0 +1,97 @@ +-- 消息模板表 +CREATE TABLE notification_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_type VARCHAR(50) NOT NULL COMMENT '模板类型', + template_content TEXT NOT NULL COMMENT '模板内容,支持 {{variable}} 变量', + is_default TINYINT(1) DEFAULT 0 COMMENT '是否使用默认模板(0=自定义,1=默认)', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uk_template_type (template_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送模板'; + +-- 插入默认模板 +INSERT INTO notification_templates (template_type, template_content, is_default, created_at, updated_at) VALUES +('ORDER_SUCCESS', '🚀 <b>订单创建成功</b> + +📊 <b>订单信息:</b> +• 订单ID: <code>{{order_id}}</code> +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} +• 可用余额: <code>{{available_balance}}</code> USDC + +⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('ORDER_FAILED', '❌ <b>订单创建失败</b> + +📊 <b>订单信息:</b> +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} + +⚠️ <b>错误信息:</b> +<code>{{error_message}}</code> + +⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('ORDER_FILTERED', '🚫 <b>订单被过滤</b> + +📊 <b>订单信息:</b> +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} + +⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code> + +📝 <b>过滤原因:</b> +<code>{{filter_reason}}</code> + +⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('CRYPTO_TAIL_SUCCESS', '🚀 <b>加密价差策略下单成功</b> + +📊 <b>订单信息:</b> +• 订单ID: <code>{{order_id}}</code> +• 策略: {{strategy_name}} +• 市场: <a href="{{market_link}}">{{market_title}}</a> +• 市场方向: <b>{{outcome}}</b> +• 方向: <b>{{side}}</b> +• 价格: <code>{{price}}</code> +• 数量: <code>{{quantity}}</code> shares +• 金额: <code>{{amount}}</code> USDC +• 账户: {{account_name}} + +⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('REDEEM_SUCCESS', '💸 <b>仓位赎回成功</b> + +📊 <b>赎回信息:</b> +• 账户: {{account_name}} +• 交易哈希: <code>{{transaction_hash}}</code> +• 赎回总价值: <code>{{total_value}}</code> USDC +• 可用余额: <code>{{available_balance}}</code> USDC + +⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('REDEEM_NO_RETURN', '📋 <b>仓位已结算(无收益)</b> + +📊 <b>结算信息:</b> +<i>市场已结算,您的预测未命中,赎回价值为 0。</i> + +• 账户: {{account_name}} +• 交易哈希: <code>{{transaction_hash}}</code> +• 可用余额: <code>{{available_balance}}</code> USDC + +⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000); diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 3cebc688..58c3d187 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -13,9 +13,18 @@ notification.order.quantity=Quantity notification.order.amount=Amount notification.order.account=Account notification.order.time=Time +notification.order.available_balance=Available Balance notification.order.error_info=Error Information notification.order.unknown_account=Unknown Account notification.order.calculate_failed=Calculation Failed +notification.order.filtered=Order Filtered +notification.order.filter_reason=Filter Reason +notification.order.filter_type=Filter Type +notification.filter.type.order_depth=Insufficient Order Depth +notification.filter.type.spread=Spread Too Large +notification.filter.type.orderbook_depth=Insufficient Orderbook Depth +notification.filter.type.price_validity=Invalid Price +notification.filter.type.market_status=Market Not Tradable notification.tail.order.success=Crypto spread strategy order success notification.tail.strategy=Strategy notification.redeem.success=Position Redeemed Successfully @@ -26,6 +35,13 @@ notification.redeem.position_count=Position Count notification.redeem.positions=Redeemed Positions notification.redeem.account=Account notification.redeem.time=Time +notification.redeem.available_balance=Available Balance + +# Position Settled (No Return) +notification.redeem.no_return.title=Position Settled (No Return) +notification.redeem.no_return.info=Settlement Information +notification.redeem.no_return.message=Market settled. Your prediction was incorrect. Redemption value is 0. +notification.redeem.no_return.positions=Settled Positions # Auto Redeem related notifications notification.auto_redeem.disabled.title=Auto Redeem Disabled diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 8d9d9612..63eede76 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -13,9 +13,18 @@ notification.order.quantity=数量 notification.order.amount=金额 notification.order.account=账户 notification.order.time=时间 +notification.order.available_balance=可用余额 notification.order.error_info=错误信息 notification.order.unknown_account=未知账户 notification.order.calculate_failed=计算失败 +notification.order.filtered=订单被过滤 +notification.order.filter_reason=过滤原因 +notification.order.filter_type=过滤类型 +notification.filter.type.order_depth=订单深度不足 +notification.filter.type.spread=价差过大 +notification.filter.type.orderbook_depth=订单簿深度不足 +notification.filter.type.price_validity=价格不合理 +notification.filter.type.market_status=市场状态不可交易 notification.tail.order.success=加密价差策略下单成功 notification.tail.strategy=策略 notification.redeem.success=仓位赎回成功 @@ -26,6 +35,13 @@ notification.redeem.position_count=仓位数量 notification.redeem.positions=赎回仓位 notification.redeem.account=账户 notification.redeem.time=时间 +notification.redeem.available_balance=可用余额 + +# 仓位已结算(无收益) +notification.redeem.no_return.title=仓位已结算(无收益) +notification.redeem.no_return.info=结算信息 +notification.redeem.no_return.message=市场已结算,您的预测未命中,赎回价值为 0。 +notification.redeem.no_return.positions=结算仓位 # 自动赎回相关通知 notification.auto_redeem.disabled.title=自动赎回未开启 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 5d06e6cb..43d493a6 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -13,9 +13,18 @@ notification.order.quantity=數量 notification.order.amount=金額 notification.order.account=賬戶 notification.order.time=時間 +notification.order.available_balance=可用餘額 notification.order.error_info=錯誤信息 notification.order.unknown_account=未知賬戶 notification.order.calculate_failed=計算失敗 +notification.order.filtered=訂單被過濾 +notification.order.filter_reason=過濾原因 +notification.order.filter_type=過濾類型 +notification.filter.type.order_depth=訂單深度不足 +notification.filter.type.spread=價差過大 +notification.filter.type.orderbook_depth=訂單簿深度不足 +notification.filter.type.price_validity=價格不合理 +notification.filter.type.market_status=市場狀態不可交易 notification.tail.order.success=加密價差策略下單成功 notification.tail.strategy=策略 notification.redeem.success=倉位贖回成功 @@ -26,6 +35,13 @@ notification.redeem.position_count=倉位數量 notification.redeem.positions=贖回倉位 notification.redeem.account=賬戶 notification.redeem.time=時間 +notification.redeem.available_balance=可用餘額 + +# 倉位已結算(無收益) +notification.redeem.no_return.title=倉位已結算(無收益) +notification.redeem.no_return.info=結算信息 +notification.redeem.no_return.message=市場已結算,您的預測未命中,贖回價值為 0。 +notification.redeem.no_return.positions=結算倉位 # 自動贖回相關通知 notification.auto_redeem.disabled.title=自動贖回未開啟 diff --git a/deploy-interactive-README.md b/deploy-interactive-README.md index de58e329..eb953d2a 100644 --- a/deploy-interactive-README.md +++ b/deploy-interactive-README.md @@ -1,4 +1,209 @@ -# PolyHermes 一键部署脚本使用说明 +# PolyHermes One-Click Deployment Script / PolyHermes 一键部署脚本使用说明 + +[English](#english) | [中文](#中文) + +--- + +<a name="english"></a> +## English + +## ✨ Core Features + +- **Run from any directory** - No need to download source code +- **Online images only** - Pull official images from Docker Hub +- **Auto-download config** - Download the latest `docker-compose.prod.yml` from GitHub +- **Interactive configuration** - User-friendly Q&A style configuration wizard +- **Auto-generate secrets** - All sensitive configurations will auto-generate secure random values on Enter + +## 🚀 Quick Start + +### One-Click Installation (Recommended) + +**Using curl (Recommended):** +```bash +mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh +``` + +**Using wget:** +```bash +mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh +``` + +This command will automatically: +- 📁 Create a dedicated working directory `~/polyhermes` +- 📥 Download the deployment script +- ✅ Check Docker environment +- ⚙️ Configure all parameters interactively (press Enter for defaults) +- 🔐 Auto-generate secure random secrets +- 🚀 Download latest images and deploy + +**Or run directly via pipe (without saving file):** +```bash +# curl method +mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash + +# wget method +mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash +``` + +### Method 1: Download and Run Script Directly + +```bash +# Download script +curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh + +# Add execute permission +chmod +x deploy-interactive.sh + +# Run +./deploy-interactive.sh +``` + +### Method 2: Run in Project Directory + +```bash +git clone https://github.com/WrBug/PolyHermes.git +cd PolyHermes +./deploy-interactive.sh +``` + +## 📝 Usage Flow + +After running the script, you will be guided through the following steps: + +``` +Step 1: Environment Check → Check Docker/Docker Compose +Step 2: Configuration → Interactive input (press Enter for defaults) +Step 3: Get Deploy Config → Download docker-compose.prod.yml from GitHub +Step 4: Generate Env File → Auto-generate .env +Step 5: Pull Docker Images → Pull latest images from Docker Hub +Step 6: Deploy Services → Start containers +Step 7: Health Check → Verify services are running properly +``` + +## ⚡ Simplest Usage + +**Press Enter for all configuration items to use default values**, the script will automatically: +- Use port 80 (application) and 3307 (MySQL) +- Generate 32-character database password +- Generate 128-character JWT secret +- Generate 64-character admin reset key +- Generate 64-character encryption key +- Configure reasonable log levels + +### Interactive Example + +The script will prompt you for configuration one by one, **press Enter to skip and use default values**: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Step 2: Configuration +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +💡 All configurations are optional, press Enter to use default or auto-generated values + +⚠ Secret config: Press Enter to auto-generate secure random secrets +⚠ Other config: Press Enter to use default values in parentheses + +【Basic Configuration】 +Will configure: Server port, MySQL port, Timezone +➤ Server port [Default: 80]: ⏎ +➤ MySQL port (external access) [Default: 3307]: ⏎ +➤ Timezone [Default: Asia/Shanghai]: ⏎ + +【Database Configuration】 +Will configure: Database username, Database password +➤ Database username [Default: root]: ⏎ +➤ Database password [Enter to auto-generate]: ⏎ +[✓] Database password auto-generated (32 characters) + +【Security Configuration】 +Will configure: JWT secret, Admin password reset key, Data encryption key +➤ JWT secret [Enter to auto-generate]: ⏎ +[✓] JWT secret auto-generated (128 characters) +➤ Admin password reset key [Enter to auto-generate]: ⏎ +[✓] Admin reset key auto-generated (64 characters) +➤ Encryption key (for API Key encryption) [Enter to auto-generate]: ⏎ +[✓] Encryption key auto-generated (64 characters) + +【Log Configuration】 +Will configure: Root log level, Application log level +Available levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF +➤ Root log level (third-party libs) [Default: WARN]: ⏎ +➤ Application log level [Default: INFO]: ⏎ + +【Other Configuration】 +Will configure: Runtime environment, Auto-update policy, GitHub repo +➤ Spring Profile [Default: prod]: ⏎ +➤ Allow prerelease updates (true/false) [Default: false]: ⏎ +➤ GitHub repository [Default: WrBug/PolyHermes]: ⏎ +``` + +## 🔧 Files Generated by Script + +After running, the script will generate in the current directory: + +1. **docker-compose.prod.yml** - Docker Compose config downloaded from GitHub (always latest) +2. **.env** - Environment variables file auto-generated based on your configuration + +These two files contain all the configuration needed to run PolyHermes. + +## 🌐 Post-Deployment Management + +### Quick Update (Recommended) + +If you already have configuration files, running the script again will detect and ask: + +```bash +./deploy-interactive.sh +``` + +``` +【Existing Configuration Detected】 +Found existing .env configuration file + +Use existing configuration to update images directly? [Y/n]: ⏎ +``` + +- **Press Enter or input Y**: Use existing config, pull latest images and update +- **Input N**: Reconfigure (existing config will be backed up) + +### Manual Management Commands + +```bash +# View service status +docker compose -f docker-compose.prod.yml ps + +# View logs +docker compose -f docker-compose.prod.yml logs -f + +# Restart services +docker compose -f docker-compose.prod.yml restart + +# Stop services +docker compose -f docker-compose.prod.yml down + +# Update to latest version +docker pull wrbug/polyhermes:latest +docker compose -f docker-compose.prod.yml up -d +``` + +## 🔐 Security Recommendations + +- **Protect .env file**: Contains sensitive information, never commit to version control +- **Backup database regularly**: Data is stored in Docker volume `mysql-data` +- **Configure HTTPS for production**: Recommend using Nginx or Caddy as reverse proxy + +## 📞 Support + +- [GitHub Repository](https://github.com/WrBug/PolyHermes) +- [Issue Feedback](https://github.com/WrBug/PolyHermes/issues) +- [Full Deployment Documentation](docs/zh/DEPLOYMENT_GUIDE.md) + +--- + +<a name="中文"></a> +## 中文 ## ✨ 核心特性 diff --git a/deploy-interactive.sh b/deploy-interactive.sh index 7ac6d191..ec0981eb 100755 --- a/deploy-interactive.sh +++ b/deploy-interactive.sh @@ -1,18 +1,19 @@ #!/bin/bash # ======================================== +# PolyHermes Interactive Deploy Script # PolyHermes 交互式一键部署脚本 # ======================================== -# 功能: -# - 交互式配置环境变量 -# - 自动生成安全密钥 -# - 使用 Docker Hub 线上镜像部署 -# - 支持配置预检和回滚 +# Features / 功能: +# - Interactive env config / 交互式配置环境变量 +# - Auto-generate secrets / 自动生成安全密钥 +# - Deploy via Docker Hub images / 使用 Docker Hub 线上镜像部署 +# - Config check and rollback / 支持配置预检和回滚 # ======================================== set -e -# 颜色输出 +# Colors / 颜色输出 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -20,7 +21,14 @@ BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color -# 打印函数 +# Language: LANG=zh* → prompts in Chinese only; else show "中文 / English" +# 语言:LANG 为 zh* 时仅中文,否则显示「中文 / English」 +USE_ZH_ONLY=false +case "${LANG:-}" in + zh*) USE_ZH_ONLY=true ;; +esac + +# Print functions / 打印函数 info() { echo -e "${GREEN}[✓]${NC} $1" } @@ -37,6 +45,17 @@ title() { echo -e "${CYAN}${1}${NC}" } +# Bilingual: 中文 / English (or Chinese only when LANG=zh*) +bilingual() { + local zh="$1" + local en="$2" + if [ "$USE_ZH_ONLY" = true ]; then + echo "$zh" + else + echo "$zh / $en" + fi +} + # 生成随机密钥 generate_secret() { local length=${1:-32} @@ -47,54 +66,57 @@ generate_secret() { fi } -# 生成随机端口号(10000-60000之间) +# 生成随机端口号(10000-60000之间)/ Generate random port (10000-60000) generate_random_port() { echo $((10000 + RANDOM % 50001)) } -# 读取用户输入(支持默认值) +# 读取用户输入(支持默认值)/ Read user input (with default) read_input() { local prompt="$1" local default="$2" local is_secret="$3" local value="" - # 构建提示信息(不使用颜色,因为 read -p 可能不支持) local prompt_text="" if [ -n "$default" ]; then if [ "$is_secret" = "secret" ]; then - prompt_text="${prompt} [回车自动生成]: " + if [ "$USE_ZH_ONLY" = true ]; then + prompt_text="${prompt} [回车自动生成]: " + else + prompt_text="${prompt} [Enter to auto-generate]: " + fi else - prompt_text="${prompt} [默认: ${default}]: " + if [ "$USE_ZH_ONLY" = true ]; then + prompt_text="${prompt} [默认: ${default}]: " + else + prompt_text="${prompt} [Default: ${default}]: " + fi fi else prompt_text="${prompt}: " fi - # 使用 read -p 确保提示正确显示 read -r -p "$prompt_text" value - # 如果用户没有输入,使用默认值 if [ -z "$value" ]; then if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then - # 自动生成密钥 case "$prompt" in - *JWT*) + *JWT*|*jwt*) value=$(generate_secret 64) - # 输出到 stderr,避免被捕获到返回值中 - info "已自动生成 JWT 密钥(128字符)" >&2 + info "$(bilingual "已自动生成 JWT 密钥(128字符)" "JWT secret auto-generated (128 chars)")" >&2 ;; - *管理员*|*ADMIN*) + *管理员*|*ADMIN*|*admin*|*reset*|*Reset*) value=$(generate_secret 32) - info "已自动生成管理员重置密钥(64字符)" >&2 + info "$(bilingual "已自动生成管理员重置密钥(64字符)" "Admin reset key auto-generated (64 chars)")" >&2 ;; - *加密*|*CRYPTO*) + *加密*|*CRYPTO*|*crypto*|*Encryption*) value=$(generate_secret 32) - info "已自动生成加密密钥(64字符)" >&2 + info "$(bilingual "已自动生成加密密钥(64字符)" "Encryption key auto-generated (64 chars)")" >&2 ;; - *数据库密码*|*DB_PASSWORD*) + *数据库密码*|*DB_PASSWORD*|*database*|*Database*) value=$(generate_secret 16) - info "已自动生成数据库密码(32字符)" >&2 + info "$(bilingual "已自动生成数据库密码(32字符)" "Database password auto-generated (32 chars)")" >&2 ;; *) value="$default" @@ -108,126 +130,115 @@ read_input() { echo "$value" } -# 检查 Docker 环境 +# 检查 Docker 环境 / Check Docker environment check_docker() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 1: 环境检查" + title " $(bilingual "步骤 1: 环境检查" "Step 1: Environment Check")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 检查 Docker if ! command -v docker &> /dev/null; then - error "Docker 未安装" + error "$(bilingual "Docker 未安装" "Docker is not installed")" echo "" - info "请先安装 Docker:" + info "$(bilingual "请先安装 Docker:" "Please install Docker first:")" info " macOS: brew install docker" info " Ubuntu/Debian: apt-get install docker.io" info " CentOS/RHEL: yum install docker" exit 1 fi - info "Docker 已安装: $(docker --version | head -1)" + info "$(bilingual "Docker 已安装" "Docker installed"): $(docker --version | head -1)" - # 检查 Docker Compose if docker compose version &> /dev/null 2>&1; then - info "Docker Compose 已安装: $(docker compose version)" + info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker compose version)" elif command -v docker-compose &> /dev/null; then - info "Docker Compose 已安装: $(docker-compose --version)" + info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker-compose --version)" else - error "Docker Compose 未安装" + error "$(bilingual "Docker Compose 未安装" "Docker Compose is not installed")" echo "" - info "请先安装 Docker Compose:" + info "$(bilingual "请先安装 Docker Compose:" "Please install Docker Compose:")" info " https://docs.docker.com/compose/install/" exit 1 fi - # 检查 Docker 守护进程 if ! docker info &> /dev/null; then - error "Docker 守护进程未运行" - info "请启动 Docker 服务:" - info " macOS: 打开 Docker Desktop" + error "$(bilingual "Docker 守护进程未运行" "Docker daemon is not running")" + info "$(bilingual "请启动 Docker 服务:" "Please start Docker:")" + info " $(bilingual "macOS: 打开 Docker Desktop" "macOS: Open Docker Desktop")" info " Linux: systemctl start docker" exit 1 fi - info "Docker 守护进程运行正常" + info "$(bilingual "Docker 守护进程运行正常" "Docker daemon is running")" echo "" } -# 交互式配置收集 +# 交互式配置收集 / Interactive configuration collect_configuration() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 2: 配置收集" + title " $(bilingual "步骤 2: 配置收集" "Step 2: Configuration")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" + info "$(bilingual "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" "💡 All options are optional, press Enter for default or auto-generated values")" echo "" - warn "密钥配置:回车将自动生成安全的随机密钥" - warn "其他配置:回车将使用括号中的默认值" + warn "$(bilingual "密钥配置:回车将自动生成安全的随机密钥" "Secrets: Enter to auto-generate secure random keys")" + warn "$(bilingual "其他配置:回车将使用括号中的默认值" "Other: Enter to use default value in brackets")" echo "" - # 基础配置 - title "【基础配置】" - echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}" - # 生成随机端口作为默认值 + title "$(bilingual "【基础配置】" "【Basic】")" + echo -e "${CYAN}$(bilingual "将配置:服务器端口、MySQL端口、时区" "Server port, MySQL port, Timezone")${NC}" DEFAULT_PORT=$(generate_random_port) - SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT") - MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307") - TZ=$(read_input "➤ 时区" "Asia/Shanghai") + SERVER_PORT=$(read_input "$(bilingual "➤ 服务器端口" "➤ Server port")" "$DEFAULT_PORT") + MYSQL_PORT=$(read_input "$(bilingual "➤ MySQL 端口(外部访问)" "➤ MySQL port (external)")" "3307") + TZ=$(read_input "$(bilingual "➤ 时区" "➤ Timezone")" "Asia/Shanghai") echo "" - # 数据库配置 - title "【数据库配置】" - echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}" - echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}" - DB_USERNAME=$(read_input "➤ 数据库用户名" "root") - DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret") + title "$(bilingual "【数据库配置】" "【Database】")" + echo -e "${CYAN}$(bilingual "将配置:数据库用户名、数据库密码" "Database username, password")${NC}" + echo -e "${YELLOW}$(bilingual "💡 提示:密码留空将自动生成 32 字符的安全随机密码" "💡 Leave password empty to auto-generate 32-char password")${NC}" + DB_USERNAME=$(read_input "$(bilingual "➤ 数据库用户名" "➤ Database username")" "root") + DB_PASSWORD=$(read_input "$(bilingual "➤ 数据库密码" "➤ Database password")" "" "secret") echo "" - # 安全配置 - title "【安全配置】" - echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}" - echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}" - JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret") - ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret") - CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret") + title "$(bilingual "【安全配置】" "【Security】")" + echo -e "${CYAN}$(bilingual "将配置:JWT密钥、管理员密码重置密钥、数据加密密钥" "JWT secret, Admin reset key, Encryption key")${NC}" + echo -e "${YELLOW}$(bilingual "💡 提示:留空将自动生成高强度随机密钥(推荐)" "💡 Leave empty to auto-generate strong keys (recommended)")${NC}" + JWT_SECRET=$(read_input "$(bilingual "➤ JWT 密钥" "➤ JWT secret")" "" "secret") + ADMIN_RESET_PASSWORD_KEY=$(read_input "$(bilingual "➤ 管理员密码重置密钥" "➤ Admin password reset key")" "" "secret") + CRYPTO_SECRET_KEY=$(read_input "$(bilingual "➤ 加密密钥(用于加密 API Key)" "➤ Encryption key (for API Key)")" "" "secret") echo "" - # 日志配置 - title "【日志配置】" - echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}" - echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}" - LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN") - LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO") + title "$(bilingual "【日志配置】" "【Logging】")" + echo -e "${CYAN}$(bilingual "将配置:Root日志级别、应用日志级别" "Root log level, App log level")${NC}" + echo -e "${YELLOW}$(bilingual "可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF" "Levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF")${NC}" + LOG_LEVEL_ROOT=$(read_input "$(bilingual "➤ Root 日志级别(第三方库)" "➤ Root log level (3rd party)")" "WARN") + LOG_LEVEL_APP=$(read_input "$(bilingual "➤ 应用日志级别" "➤ App log level")" "INFO") echo "" - # 自动设置不需要用户输入的配置 SPRING_PROFILES_ACTIVE="prod" ALLOW_PRERELEASE="false" GITHUB_REPO="WrBug/PolyHermes" } -# 下载 docker-compose.prod.yml(如果不存在) +# 下载 docker-compose.prod.yml(如果不存在)/ Download docker-compose.prod.yml if missing download_docker_compose_file() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 3: 获取部署配置" + title " $(bilingual "步骤 3: 获取部署配置" "Step 3: Get Deploy Config")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [ -f "docker-compose.prod.yml" ]; then - info "检测到现有 docker-compose.prod.yml,跳过下载" + info "$(bilingual "检测到现有 docker-compose.prod.yml,跳过下载" "Existing docker-compose.prod.yml found, skip download")" echo "" return 0 fi - info "正在从 GitHub 下载 docker-compose.prod.yml..." + info "$(bilingual "正在从 GitHub 下载 docker-compose.prod.yml..." "Downloading docker-compose.prod.yml from GitHub...")" - # GitHub raw 文件链接 local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml" - # 尝试下载 if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then - info "docker-compose.prod.yml 下载成功" + info "$(bilingual "docker-compose.prod.yml 下载成功" "docker-compose.prod.yml downloaded")" else - error "docker-compose.prod.yml 下载失败" - warn "请检查网络连接或手动下载:" + error "$(bilingual "docker-compose.prod.yml 下载失败" "Failed to download docker-compose.prod.yml")" + warn "$(bilingual "请检查网络连接或手动下载:" "Check network or download manually:")" warn " $compose_url" exit 1 fi @@ -235,28 +246,26 @@ download_docker_compose_file() { echo "" } -# 生成 .env 文件 +# 生成 .env 文件 / Generate .env file generate_env_file() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 4: 生成环境变量文件" + title " $(bilingual "步骤 4: 生成环境变量文件" "Step 4: Generate .env")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 备份现有 .env 文件 if [ -f ".env" ]; then BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)" cp .env "$BACKUP_FILE" - warn "已备份现有配置文件到: $BACKUP_FILE" + warn "$(bilingual "已备份现有配置文件到" "Backed up existing config to"): $BACKUP_FILE" fi - # 生成新的 .env 文件 cat > .env <<EOF # ======================================== -# PolyHermes 生产环境配置 -# 生成时间: $(date '+%Y-%m-%d %H:%M:%S') +# PolyHermes Production Config / 生产环境配置 +# Generated / 生成时间: $(date '+%Y-%m-%d %H:%M:%S') # ======================================== # ============================================ -# 基础配置 +# Basic / 基础配置 # ============================================ TZ=${TZ} SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} @@ -264,112 +273,107 @@ SERVER_PORT=${SERVER_PORT} MYSQL_PORT=${MYSQL_PORT} # ============================================ -# 数据库配置 +# Database / 数据库配置 # ============================================ DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true DB_USERNAME=${DB_USERNAME} DB_PASSWORD=${DB_PASSWORD} # ============================================ -# 安全配置(请妥善保管) +# Security (keep safe) / 安全配置(请妥善保管) # ============================================ JWT_SECRET=${JWT_SECRET} ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY} CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY} # ============================================ -# 日志配置 +# Logging / 日志配置 # ============================================ LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT} LOG_LEVEL_APP=${LOG_LEVEL_APP} # ============================================ -# 其他配置 +# Other / 其他配置 # ============================================ ALLOW_PRERELEASE=${ALLOW_PRERELEASE} GITHUB_REPO=${GITHUB_REPO} EOF - info "配置文件已生成: .env" + info "$(bilingual "配置文件已生成" "Config file generated"): .env" echo "" - # 显示配置摘要 - title "【配置摘要】" - echo " 服务器端口: ${SERVER_PORT}" - echo " MySQL 端口: ${MYSQL_PORT}" - echo " 时区: ${TZ}" - echo " 数据库用户: ${DB_USERNAME}" - echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)" - echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)" - echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)" - echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)" - echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}" + title "$(bilingual "【配置摘要】" "【Config Summary】")" + echo " $(bilingual "服务器端口" "Server port"): ${SERVER_PORT}" + echo " $(bilingual "MySQL 端口" "MySQL port"): ${MYSQL_PORT}" + echo " $(bilingual "时区" "Timezone"): ${TZ}" + echo " $(bilingual "数据库用户" "DB user"): ${DB_USERNAME}" + echo " $(bilingual "数据库密码" "DB password"): ${DB_PASSWORD:0:8}... $(bilingual "(已隐藏)" "(hidden)")" + echo " $(bilingual "JWT 密钥" "JWT secret"): ${JWT_SECRET:0:16}... $(bilingual "(已隐藏)" "(hidden)")" + echo " $(bilingual "管理员重置密钥" "Admin reset key"): ${ADMIN_RESET_PASSWORD_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")" + echo " $(bilingual "加密密钥" "Encryption key"): ${CRYPTO_SECRET_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")" + echo " $(bilingual "日志级别" "Log level"): Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}" echo "" } -# 拉取镜像 +# 拉取镜像 / Pull images pull_images() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 5: 拉取 Docker 镜像" + title " $(bilingual "步骤 5: 拉取 Docker 镜像" "Step 5: Pull Docker Images")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - info "正在从 Docker Hub 拉取最新镜像..." + info "$(bilingual "正在从 Docker Hub 拉取最新镜像..." "Pulling latest images from Docker Hub...")" - # 拉取应用镜像 if docker pull wrbug/polyhermes:latest; then - info "应用镜像拉取成功: wrbug/polyhermes:latest" + info "$(bilingual "应用镜像拉取成功" "App image pulled"): wrbug/polyhermes:latest" else - error "应用镜像拉取失败" - warn "可能的原因:" - warn " 1. 网络连接问题" - warn " 2. Docker Hub 服务异常" - warn " 3. 镜像不存在" + error "$(bilingual "应用镜像拉取失败" "Failed to pull app image")" + warn "$(bilingual "可能的原因:" "Possible reasons:")" + warn " 1. $(bilingual "网络连接问题" "Network issue")" + warn " 2. $(bilingual "Docker Hub 服务异常" "Docker Hub unavailable")" + warn " 3. $(bilingual "镜像不存在" "Image not found")" exit 1 fi - # 拉取 MySQL 镜像 if docker pull mysql:8.2; then - info "MySQL 镜像拉取成功: mysql:8.2" + info "$(bilingual "MySQL 镜像拉取成功" "MySQL image pulled"): mysql:8.2" else - warn "MySQL 镜像拉取失败,将在启动时自动下载" + warn "$(bilingual "MySQL 镜像拉取失败,将在启动时自动下载" "MySQL pull failed, will download on start")" fi echo "" } -# 部署服务 +# 部署服务 / Deploy services deploy_services() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 6: 部署服务" + title " $(bilingual "步骤 6: 部署服务" "Step 6: Deploy Services")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 停止现有服务 if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then - warn "检测到正在运行的服务,正在停止..." + warn "$(bilingual "检测到正在运行的服务,正在停止..." "Stopping existing services...")" docker compose -f docker-compose.prod.yml down - info "已停止现有服务" + info "$(bilingual "已停止现有服务" "Stopped existing services")" fi - # 启动服务 - info "正在启动服务..." + info "$(bilingual "正在启动服务..." "Starting services...")" if docker compose -f docker-compose.prod.yml up -d; then - info "服务启动成功" + info "$(bilingual "服务启动成功" "Services started")" else - error "服务启动失败" - error "请检查日志: docker compose -f docker-compose.prod.yml logs" + error "$(bilingual "服务启动失败" "Failed to start services")" + error "$(bilingual "请检查日志" "Check logs"): docker compose -f docker-compose.prod.yml logs" exit 1 fi echo "" } -# 健康检查 +# 健康检查 / Health check health_check() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 7: 健康检查" + title " $(bilingual "步骤 7: 健康检查" "Step 7: Health Check")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - info "等待服务启动(最多等待 60 秒)..." + info "$(bilingual "等待服务启动(最多等待 60 秒)..." "Waiting for services (up to 60s)...")" local max_attempts=12 local attempt=0 @@ -377,13 +381,11 @@ health_check() { while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) - # 检查容器状态 if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then - info "容器运行正常" + info "$(bilingual "容器运行正常" "Containers are up")" - # 检查应用是否响应 if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then - info "应用响应正常" + info "$(bilingual "应用响应正常" "App is responding")" echo "" return 0 fi @@ -394,79 +396,76 @@ health_check() { done echo "" - warn "健康检查超时,请手动检查服务状态" - warn "查看日志: docker compose -f docker-compose.prod.yml logs -f" + warn "$(bilingual "健康检查超时,请手动检查服务状态" "Health check timeout, please check services manually")" + warn "$(bilingual "查看日志" "View logs"): docker compose -f docker-compose.prod.yml logs -f" echo "" } -# 显示部署信息 +# 显示部署信息 / Show deployment info show_deployment_info() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 部署完成!" + title " $(bilingual "部署完成!" "Deployment Complete!")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}" + info "$(bilingual "访问地址" "Access URL"): ${GREEN}http://localhost:${SERVER_PORT}${NC}" echo "" - title "【常用命令】" - echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}" - echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" - echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}" - echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}" - echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}" + title "$(bilingual "【常用命令】" "【Common Commands】")" + echo -e " $(bilingual "查看服务状态" "Status"): ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}" + echo -e " $(bilingual "查看日志" "Logs"): ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" + echo -e " $(bilingual "停止服务" "Stop"): ${CYAN}docker compose -f docker-compose.prod.yml down${NC}" + echo -e " $(bilingual "重启服务" "Restart"): ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}" + echo -e " $(bilingual "更新镜像" "Update"): ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}" echo "" - title "【数据库连接信息】" - echo -e " 主机: ${CYAN}localhost${NC}" - echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}" - echo -e " 数据库: ${CYAN}polyhermes${NC}" - echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}" - echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}" + title "$(bilingual "【数据库连接信息】" "【Database Connection】")" + echo -e " $(bilingual "主机" "Host"): ${CYAN}localhost${NC}" + echo -e " $(bilingual "端口" "Port"): ${CYAN}${MYSQL_PORT}${NC}" + echo -e " $(bilingual "数据库" "Database"): ${CYAN}polyhermes${NC}" + echo -e " $(bilingual "用户名" "Username"): ${CYAN}${DB_USERNAME}${NC}" + echo -e " $(bilingual "密码" "Password"): ${CYAN}${DB_PASSWORD}${NC}" echo "" - title "【管理员重置密钥】" - echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}" - echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}" + title "$(bilingual "【管理员重置密钥】" "【Admin Reset Key】")" + echo -e " $(bilingual "重置密钥" "Reset key"): ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}" + echo -e " ${YELLOW}$(bilingual "💡 此密钥用于重置管理员密码,请妥善保管" "💡 Keep this key safe; it is used to reset admin password")${NC}" echo "" - warn "重要提示:" - warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统" - warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" - warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" + warn "$(bilingual "重要提示:" "Important:")" + warn " 1. $(bilingual "请妥善保管 .env 文件,勿提交到版本控制系统" "Keep .env secure; do not commit to version control")" + warn " 2. $(bilingual "定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" "Back up DB regularly (Docker volume: polyhermes_mysql-data)")" + warn " 3. $(bilingual "生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" "Use a reverse proxy (e.g. Nginx) and HTTPS in production")" echo "" } -# 主函数 +# 主函数 / Main main() { clear echo "" title "=========================================" - title " PolyHermes 交互式一键部署脚本 " + title " $(bilingual "PolyHermes 交互式一键部署脚本" "PolyHermes Interactive Deploy") " title "=========================================" echo "" - # 执行部署流程 check_docker - # 检查是否已存在 .env 文件 if [ -f ".env" ]; then echo "" - title "【检测到现有配置】" - info "发现已存在的 .env 配置文件" + title "$(bilingual "【检测到现有配置】" "【Existing Config Found】")" + info "$(bilingual "发现已存在的 .env 配置文件" "Found existing .env file")" echo "" - echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}" + echo -ne "${YELLOW}$(bilingual "是否使用现有配置直接更新镜像?[Y/n]" "Use existing config to update images? [Y/n]"): ${NC}" read -r use_existing use_existing=${use_existing:-Y} if [[ "$use_existing" =~ ^[Yy]$ ]]; then - info "将使用现有配置,跳过配置步骤" + info "$(bilingual "将使用现有配置,跳过配置步骤" "Using existing config, skipping configuration")" echo "" - # 从现有 .env 文件读取必要的变量 source .env 2>/dev/null || true else - warn "将重新配置,现有配置将被备份" + warn "$(bilingual "将重新配置,现有配置将被备份" "Will reconfigure; existing config will be backed up")" echo "" collect_configuration fi @@ -476,21 +475,18 @@ main() { download_docker_compose_file - # 只有在重新配置时才生成新的 .env 文件 if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then generate_env_file fi - # 确认部署 echo "" - title "【确认部署】" - echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}" + title "$(bilingual "【确认部署】" "【Confirm Deploy】")" + echo -ne "${YELLOW}$(bilingual "是否开始部署?[Y/n](回车默认为是)" "Start deployment? [Y/n] (Enter = Yes)"): ${NC}" read -r confirm - # 默认为 Y,只有明确输入 n/N 才取消 confirm=${confirm:-Y} if [[ "$confirm" =~ ^[Nn]$ ]]; then - warn "部署已取消" + warn "$(bilingual "部署已取消" "Deployment cancelled")" exit 0 fi @@ -500,11 +496,11 @@ main() { health_check show_deployment_info - info "部署流程已完成!" + info "$(bilingual "部署流程已完成!" "Deployment finished!")" } -# 捕获 Ctrl+C -trap 'echo ""; warn "部署已中断"; exit 1' INT +# 捕获 Ctrl+C / Handle Ctrl+C +trap 'echo ""; warn "$(bilingual "部署已中断" "Deployment interrupted")"; exit 1' INT -# 运行主函数 +# 运行主函数 / Run main main "$@" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8c0faa7..caeaf1e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders' import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' +import NotificationSettingsPage from './pages/NotificationSettingsPage' import ApiHealthStatus from './pages/ApiHealthStatus' import RpcNodeSettings from './pages/RpcNodeSettings' import Announcements from './pages/Announcements' @@ -116,28 +117,37 @@ function App() { } } - // 优先使用订单详情中的数据,如果没有则使用 WebSocket 消息中的数据 - const price = orderDetail ? parseFloat(orderDetail.price).toFixed(4) : parseFloat(order.price).toFixed(4) - const size = orderDetail ? parseFloat(orderDetail.size).toFixed(2) : parseFloat(order.original_size).toFixed(2) - const filled = orderDetail ? parseFloat(orderDetail.filled).toFixed(2) : parseFloat(order.size_matched).toFixed(2) + // 实际成交价 = original_size*price/size_matched;有成交时数量用 size_matched + const size = orderDetail ? orderDetail.size : order.original_size + const filled = orderDetail ? orderDetail.filled : order.size_matched + const sizeNum = parseFloat(size).toFixed(2) + const filledNum = parseFloat(filled).toFixed(2) + const hasFilled = parseFloat(filled) > 0 + const price = orderDetail + ? (orderDetail.avgFilledPrice ?? orderDetail.price) + : (hasFilled + ? (parseFloat(order.original_size) * parseFloat(order.price) / parseFloat(order.size_matched)).toString() + : order.price) + const priceStr = parseFloat(price).toFixed(4) const status = orderDetail?.status || 'UNKNOWN' - + // 有成交时展示数量用 size_matched(filled),否则用 original_size + const displaySize = (orderDetail?.avgFilledPrice || (orderDetail == null && hasFilled)) ? filledNum : sizeNum + // 构建描述信息 - let description = `${t('order.market')}: ${marketName}\n${sideText} ${size} @ ${price}` + let description = `${t('order.market')}: ${marketName}\n${sideText} ${displaySize} @ ${priceStr}` // 如果有订单详情,显示更详细的信息 if (orderDetail) { description += `\n${t('order.status')}: ${status}` - if (parseFloat(filled) > 0) { - description += ` | ${t('order.filled')}: ${filled}` + if (parseFloat(filledNum) > 0) { + description += ` | ${t('order.filled')}: ${filledNum}` } - const remaining = (parseFloat(size) - parseFloat(filled)).toFixed(2) + const remaining = (parseFloat(sizeNum) - parseFloat(filledNum)).toFixed(2) if (parseFloat(remaining) > 0) { description += ` | ${t('order.remaining')}: ${remaining}` } } else if (order.type === 'UPDATE' && parseFloat(order.size_matched) > 0) { - // 如果没有订单详情,使用 WebSocket 消息中的已成交数量 - description += `\n${t('order.filled')}: ${filled}` + description += `\n${t('order.filled')}: ${filledNum}` } // 根据订单类型选择通知类型 @@ -268,6 +278,7 @@ function App() { <Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} /> <Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} /> <Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} /> + <Route path="/system-settings/notification" element={<ProtectedRoute><NotificationSettingsPage /></ProtectedRoute>} /> <Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} /> {/* 默认重定向到登录页 */} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6f4239a7..4ddd5750 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -207,6 +207,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => { icon: <SettingOutlined />, label: t('menu.systemOverview') || '通用设置' }, + { + key: '/system-settings/notification', + icon: <NotificationOutlined />, + label: t('menu.notifications') || '消息推送设置' + }, { key: '/system-settings/rpc-nodes', icon: <ApiOutlined />, diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index bd68b3c6..48dae459 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -42,7 +42,8 @@ "pageOf": "Page", "ascending": "Ascending", "descending": "Descending", - "day": "day" + "day": "day", + "orders": "orders" }, "account": { "title": "Account Management", @@ -176,7 +177,8 @@ "updateFailed": "Failed to update account", "getDetailFailedForEdit": "Failed to get account detail", "loading": "Loading...", - "fetchFailed": "Failed to get account list" + "fetchFailed": "Failed to get account list", + "noData": "No account data" }, "accountImport": { "title": "Import Account", @@ -660,7 +662,8 @@ "deleteSuccess": "User deleted successfully", "deleteFailed": "Failed to delete user", "deleteConfirm": "Are you sure you want to delete this user?", - "total": "Total {{total}} items" + "total": "Total {{total}} items", + "noData": "No user data" }, "statistics": { "title": "Statistics", @@ -1121,7 +1124,9 @@ "updateStatusFailed": "Failed to update copy trading status", "deleteSuccess": "Copy trading deleted successfully", "deleteFailed": "Failed to delete copy trading", - "deleteConfirm": "Are you sure you want to delete this copy trading relationship?" + "deleteConfirm": "Are you sure you want to delete this copy trading relationship?", + "profitRate": "Profit Rate", + "noData": "No copy trading configuration" }, "notificationSettings": { "title": "Notification Settings", @@ -1162,7 +1167,95 @@ "getChatIdsFailed": "Failed to get Chat IDs", "getChatIdsNoToken": "Please enter Bot Token first", "getChatIdsNoMessage": "Chat ID not found, please send a message to the bot first (e.g., /start), then retry", - "getChatIdsButton": "Get Chat ID" + "getChatIdsButton": "Get Chat ID", + "botConfig": "Bot Configuration", + "templateConfig": "Template Configuration", + "templates": { + "title": "Message Template Configuration", + "templateType": "Template Type", + "templateContent": "Template Content", + "isDefault": "Default Template", + "isCustom": "Custom Template", + "resetToDefault": "Reset to Default", + "resetConfirm": "Are you sure you want to reset to default? Your custom content will be lost.", + "resetSuccess": "Reset successfully", + "resetFailed": "Reset failed", + "saveSuccess": "Saved successfully", + "saveFailed": "Save failed", + "testSuccess": "Test message sent successfully, please check Telegram", + "testFailed": "Failed to send test message", + "variables": "Available Variables", + "clickToCopy": "Click to copy", + "copied": "Copied", + "commonVariables": "Common Variables", + "orderVariables": "Order Variables", + "copyTradingVariables": "Copy Trading Variables", + "redeemVariables": "Redeem Variables", + "errorVariables": "Error Variables", + "filterVariables": "Filter Variables", + "strategyVariables": "Strategy Variables", + "contentPlaceholder": "Enter template content here, use {{variable}} to insert dynamic content", + "variableLabels": { + "account_name": "Account Name", + "wallet_address": "Wallet Address", + "time": "Time", + "order_id": "Order ID", + "market_title": "Market Title", + "market_link": "Market Link", + "side": "Side", + "outcome": "Outcome", + "price": "Price", + "quantity": "Quantity", + "amount": "Amount", + "available_balance": "Available Balance", + "leader_name": "Leader Name", + "config_name": "Copy Trading Config Name", + "error_message": "Error Message", + "filter_type": "Filter Type", + "filter_reason": "Filter Reason", + "strategy_name": "Strategy Name", + "transaction_hash": "Transaction Hash", + "total_value": "Total Redeem Value" + }, + "variableDescriptions": { + "account_name": "Account name executing the order", + "wallet_address": "Wallet address (masked)", + "time": "Notification send time", + "order_id": "Unique order identifier", + "market_title": "Market/Event name", + "market_link": "Polymarket market link", + "side": "Order direction (Buy/Sell)", + "outcome": "Market outcome (YES/NO, etc.)", + "price": "Order price", + "quantity": "Order quantity (shares)", + "amount": "Order amount (USDC)", + "available_balance": "Account available balance (USDC)", + "leader_name": "Leader name/alias being copied", + "config_name": "Copy trading configuration name", + "error_message": "Order failure reason", + "filter_type": "Type of order filter", + "filter_reason": "Detailed reason for order filtering", + "strategy_name": "Crypto spread strategy name", + "transaction_hash": "Redeem transaction hash", + "total_value": "Total redeem value (USDC)" + } + }, + "templateTypes": { + "ORDER_SUCCESS": "Order Success", + "ORDER_FAILED": "Order Failed", + "ORDER_FILTERED": "Order Filtered", + "CRYPTO_TAIL_SUCCESS": "Crypto Spread Strategy Success", + "REDEEM_SUCCESS": "Position Redeem Success", + "REDEEM_NO_RETURN": "Position Settled (No Return)" + }, + "templateTypeDescriptions": { + "ORDER_SUCCESS": "Notification sent when order is successfully created", + "ORDER_FAILED": "Notification sent when order creation fails", + "ORDER_FILTERED": "Notification sent when order is filtered by risk control", + "CRYPTO_TAIL_SUCCESS": "Notification sent when crypto spread strategy order succeeds", + "REDEEM_SUCCESS": "Notification sent when position is successfully redeemed", + "REDEEM_NO_RETURN": "Notification sent when position is settled with no return" + } }, "telegramConfig": { "title": "Telegram Configuration Guide", @@ -1351,6 +1444,8 @@ "title": "Backtest", "taskName": "Task Name", "leader": "Leader", + "balance": "Balance (Init→Final)", + "profit": "Profit (Amount/Rate)", "initialBalance": "Initial Balance", "backtestDays": "Backtest Days", "status": "Status", @@ -1399,6 +1494,7 @@ "createCopyTradingSuccess": "Copy trading config created successfully", "noTasks": "No backtest tasks", "noTrades": "No trade records", + "noData": "No backtest data", "fetchTasksFailed": "Failed to fetch task list", "fetchTaskDetailFailed": "Failed to fetch task detail", "fetchTradesFailed": "Failed to fetch trade records", @@ -1496,7 +1592,9 @@ "strategyName": "Strategy Name", "account": "Account", "market": "Market", + "marketAndTime": "Market / Time", "timeWindow": "Time Window", + "config": "Config", "priceRange": "Price Range", "amountMode": "Amount Mode", "ratio": "Ratio", @@ -1509,6 +1607,7 @@ "disable": "Disable", "delete": "Delete", "viewTriggers": "Orders", + "viewPnlCurve": "PnL Curve", "deleteConfirm": "Delete this strategy?", "fetchFailed": "Failed to fetch list", "configGuide": "Configuration Guide" @@ -1576,6 +1675,20 @@ "emptySuccess": "No success records", "emptyFail": "No failed records", "totalCount": "{count} record(s)" + }, + "pnlCurve": { + "title": "PnL Curve", + "totalPnl": "Total PnL", + "settledCount": "Settled", + "winRate": "Win Rate", + "maxDrawdown": "Max Drawdown", + "timeRange": "Time Range", + "today": "Today", + "last7Days": "Last 7 Days", + "last30Days": "Last 30 Days", + "all": "All", + "customRange": "Custom", + "empty": "No settled orders yet, cannot show PnL curve" } }, "cryptoTailMonitor": { @@ -1644,6 +1757,49 @@ "switchToLatest": "Switch to Latest Period", "periodEnded": "Current period has ended", "newPeriodAvailable": "New period has started" + }, + "manualOrder": { + "title": "Manual Order", + "buttonUp": "Buy Up", + "buttonDown": "Buy Down", + "confirmTitle": "Manual Order Confirmation", + "marketTitle": "Market Title", + "direction": "Direction", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "Order Price", + "orderSize": "Order Size", + "totalAmount": "Total Amount", + "account": "Account", + "cancel": "Cancel", + "confirm": "Confirm Order", + "orderUnit": "USDC", + "sizeUnit": "shares", + "statusNotOrdered": "Not Ordered", + "statusOrdered": "This period has been ordered", + "statusOrderedAuto": "This period has been auto-ordered", + "statusOrderedManual": "This period has been manually ordered", + "errorInsufficientBalance": "Insufficient account balance, available: {balance} USDC", + "errorPriceOutOfRange": "Price must be between 0 and 1", + "errorMinSize": "Size cannot be less than 1 share", + "errorExceedsBalance": "Total amount exceeds available balance", + "errorPriceRequired": "Please enter order price", + "errorSizeRequired": "Please enter order size", + "success": "Manual order successful", + "failed": "Manual order failed: {{reason}}", + "errorSigning": "Signing failed: {reason}", + "errorNetwork": "Network error, please try again later", + "priceNotLoaded": "Price data not loaded", + "insufficientBalance": "Insufficient balance, available amount less than 1 USDC", + "fetchBalanceFailed": "Failed to fetch account balance", + "availableBalance": "Available Balance", + "fetchLatestPrice": "Latest Price", + "priceUpdated": "Price updated", + "maxSize": "Max", + "invalidPriceOrBalance": "Invalid price or balance", + "insufficientBalanceForMax": "Insufficient balance for max size", + "maxSizeUpdated": "Updated to max size", + "periodChanged": "Period has changed, popup closed" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 8f442c8e..a39316d7 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -42,7 +42,8 @@ "copyFailed": "复制失败", "ascending": "升序", "descending": "降序", - "day": "天" + "day": "天", + "orders": "单" }, "login": { "title": "登录", @@ -176,7 +177,8 @@ "updateFailed": "更新账户失败", "getDetailFailedForEdit": "获取账户详情失败", "loading": "加载中...", - "fetchFailed": "获取账户列表失败" + "fetchFailed": "获取账户列表失败", + "noData": "暂无账户数据" }, "accountImport": { "title": "导入账户", @@ -660,7 +662,8 @@ "deleteSuccess": "删除用户成功", "deleteFailed": "删除用户失败", "deleteConfirm": "确定要删除这个用户吗?", - "total": "共 {{total}} 条" + "total": "共 {{total}} 条", + "noData": "暂无用户数据" }, "statistics": { "title": "统计信息", @@ -1121,7 +1124,9 @@ "updateStatusFailed": "更新跟单状态失败", "deleteSuccess": "删除跟单成功", "deleteFailed": "删除跟单失败", - "deleteConfirm": "确定要删除这个跟单关系吗?" + "deleteConfirm": "确定要删除这个跟单关系吗?", + "profitRate": "收益率", + "noData": "暂无跟单配置" }, "notificationSettings": { "title": "消息推送设置", @@ -1162,7 +1167,95 @@ "getChatIdsFailed": "获取 Chat IDs 失败", "getChatIdsNoToken": "请先填写 Bot Token", "getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试", - "getChatIdsButton": "获取 Chat ID" + "getChatIdsButton": "获取 Chat ID", + "botConfig": "机器人配置", + "templateConfig": "模板配置", + "templates": { + "title": "消息模板配置", + "templateType": "模板类型", + "templateContent": "模板内容", + "isDefault": "默认模板", + "isCustom": "自定义模板", + "resetToDefault": "重置为默认", + "resetConfirm": "确定要重置为默认模板吗?您的自定义内容将丢失。", + "resetSuccess": "重置成功", + "resetFailed": "重置失败", + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "testSuccess": "测试消息发送成功,请检查 Telegram", + "testFailed": "测试消息发送失败", + "variables": "可用变量", + "clickToCopy": "点击复制", + "copied": "已复制", + "commonVariables": "通用变量", + "orderVariables": "订单变量", + "copyTradingVariables": "跟单变量", + "redeemVariables": "赎回变量", + "errorVariables": "错误变量", + "filterVariables": "过滤变量", + "strategyVariables": "策略变量", + "contentPlaceholder": "在此输入模板内容,使用 {{变量名}} 插入动态内容", + "variableLabels": { + "account_name": "账户名称", + "wallet_address": "钱包地址", + "time": "时间", + "order_id": "订单ID", + "market_title": "市场标题", + "market_link": "市场链接", + "side": "方向", + "outcome": "市场方向", + "price": "价格", + "quantity": "数量", + "amount": "金额", + "available_balance": "可用余额", + "leader_name": "Leader 名称", + "config_name": "跟单配置名", + "error_message": "错误信息", + "filter_type": "过滤类型", + "filter_reason": "过滤原因", + "strategy_name": "策略名称", + "transaction_hash": "交易哈希", + "total_value": "赎回总价值" + }, + "variableDescriptions": { + "account_name": "执行订单的账户名称", + "wallet_address": "钱包地址(已脱敏)", + "time": "通知发送时间", + "order_id": "订单唯一标识", + "market_title": "市场/事件名称", + "market_link": "Polymarket 市场链接", + "side": "订单方向(买入/卖出)", + "outcome": "市场方向(YES/NO 等)", + "price": "订单价格", + "quantity": "订单数量(shares)", + "amount": "订单金额(USDC)", + "available_balance": "账户可用余额(USDC)", + "leader_name": "跟单的 Leader 名称/备注", + "config_name": "跟单配置名称", + "error_message": "订单失败原因", + "filter_type": "订单被过滤的类型", + "filter_reason": "订单被过滤的详细原因", + "strategy_name": "加密价差策略名称", + "transaction_hash": "赎回交易的哈希值", + "total_value": "赎回的总价值(USDC)" + } + }, + "templateTypes": { + "ORDER_SUCCESS": "订单成功通知", + "ORDER_FAILED": "订单失败通知", + "ORDER_FILTERED": "订单过滤通知", + "CRYPTO_TAIL_SUCCESS": "加密价差策略成功通知", + "REDEEM_SUCCESS": "仓位赎回成功通知", + "REDEEM_NO_RETURN": "仓位结算(无收益)通知" + }, + "templateTypeDescriptions": { + "ORDER_SUCCESS": "订单创建成功时发送的通知", + "ORDER_FAILED": "订单创建失败时发送的通知", + "ORDER_FILTERED": "订单被风控过滤时发送的通知", + "CRYPTO_TAIL_SUCCESS": "加密价差策略下单成功时发送的通知", + "REDEEM_SUCCESS": "仓位赎回成功时发送的通知", + "REDEEM_NO_RETURN": "仓位结算但无收益时发送的通知" + } }, "telegramConfig": { "title": "Telegram 配置说明", @@ -1351,6 +1444,8 @@ "title": "回测", "taskName": "任务名称", "leader": "Leader", + "balance": "资金 (初始→最终)", + "profit": "收益 (金额/比例)", "initialBalance": "初始资金", "backtestDays": "回测天数", "status": "状态", @@ -1399,6 +1494,7 @@ "createCopyTradingSuccess": "跟单配置创建成功", "noTasks": "暂无回测任务", "noTrades": "暂无交易记录", + "noData": "暂无回测数据", "fetchTasksFailed": "获取任务列表失败", "fetchTaskDetailFailed": "获取任务详情失败", "fetchTradesFailed": "获取交易记录失败", @@ -1496,7 +1592,9 @@ "strategyName": "策略名称", "account": "账户", "market": "关联市场", + "marketAndTime": "市场/时间", "timeWindow": "时间区间", + "config": "配置", "priceRange": "价格区间", "amountMode": "投入方式", "ratio": "比例", @@ -1509,6 +1607,7 @@ "disable": "停用", "delete": "删除", "viewTriggers": "订单", + "viewPnlCurve": "收益曲线", "deleteConfirm": "确定删除该策略?", "fetchFailed": "获取列表失败", "configGuide": "配置指南" @@ -1576,6 +1675,20 @@ "emptySuccess": "暂无成功记录", "emptyFail": "暂无失败记录", "totalCount": "共 {count} 条" + }, + "pnlCurve": { + "title": "收益曲线", + "totalPnl": "总收益", + "settledCount": "已结算笔数", + "winRate": "胜率", + "maxDrawdown": "最大回撤", + "timeRange": "时间范围", + "today": "今日", + "last7Days": "近7天", + "last30Days": "近30天", + "all": "全部", + "customRange": "自定义", + "empty": "暂无已结算订单,无法展示收益曲线" } }, "cryptoTailMonitor": { @@ -1644,6 +1757,49 @@ "switchToLatest": "切换到最新周期", "periodEnded": "当前周期已结束", "newPeriodAvailable": "新周期已开始" + }, + "manualOrder": { + "title": "手动下单", + "buttonUp": "买入 Up", + "buttonDown": "买入 Down", + "confirmTitle": "手动下单确认", + "marketTitle": "市场标题", + "direction": "方向", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "下单价格", + "orderSize": "下单数量", + "totalAmount": "总金额", + "account": "账户", + "cancel": "取消", + "confirm": "确认下单", + "orderUnit": "USDC", + "sizeUnit": "张", + "statusNotOrdered": "未下单", + "statusOrdered": "本周期已下单", + "statusOrderedAuto": "本周期已自动下单", + "statusOrderedManual": "本周期已手动下单", + "errorInsufficientBalance": "账户余额不足,可用余额:{{balance}} USDC", + "errorPriceOutOfRange": "价格必须在 0~1 之间", + "errorMinSize": "数量不能少于 1 张", + "errorExceedsBalance": "总金额超过可用余额", + "errorPriceRequired": "请输入下单价格", + "errorSizeRequired": "请输入下单数量", + "success": "手动下单成功", + "failed": "手动下单失败:{{reason}}", + "errorSigning": "签名失败:{{reason}}", + "errorNetwork": "网络错误,请稍后重试", + "priceNotLoaded": "价格数据未加载", + "insufficientBalance": "余额不足,可用金额少于 1 USDC", + "fetchBalanceFailed": "获取账户余额失败", + "availableBalance": "可用余额", + "fetchLatestPrice": "获取最新价", + "priceUpdated": "价格已更新", + "maxSize": "最大", + "invalidPriceOrBalance": "价格或余额无效", + "insufficientBalanceForMax": "余额不足以购买最大数量", + "maxSizeUpdated": "已更新为最大数量", + "periodChanged": "周期已切换,弹窗已关闭" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index a0d8fd2c..beebeaa1 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -42,7 +42,8 @@ "pageOf": "第", "ascending": "升序", "descending": "降序", - "day": "天" + "day": "天", + "orders": "單" }, "account": { "title": "賬戶管理", @@ -176,7 +177,8 @@ "updateFailed": "更新賬戶失敗", "getDetailFailedForEdit": "獲取賬戶詳情失敗", "loading": "加載中...", - "fetchFailed": "獲取賬戶列表失敗" + "fetchFailed": "獲取賬戶列表失敗", + "noData": "暫無賬戶數據" }, "accountImport": { "title": "導入賬戶", @@ -660,7 +662,8 @@ "deleteSuccess": "刪除用戶成功", "deleteFailed": "刪除用戶失敗", "deleteConfirm": "確定要刪除這個用戶嗎?", - "total": "共 {{total}} 條" + "total": "共 {{total}} 條", + "noData": "暫無用戶數據" }, "statistics": { "title": "統計信息", @@ -1121,7 +1124,9 @@ "updateStatusFailed": "更新跟單狀態失敗", "deleteSuccess": "刪除跟單成功", "deleteFailed": "刪除跟單失敗", - "deleteConfirm": "確定要刪除這個跟單關係嗎?" + "deleteConfirm": "確定要刪除這個跟單關係嗎?", + "profitRate": "收益率", + "noData": "暫無跟單配置" }, "notificationSettings": { "title": "消息推送設置", @@ -1162,7 +1167,95 @@ "getChatIdsFailed": "獲取 Chat IDs 失敗", "getChatIdsNoToken": "請先填寫 Bot Token", "getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試", - "getChatIdsButton": "獲取 Chat ID" + "getChatIdsButton": "獲取 Chat ID", + "botConfig": "機器人配置", + "templateConfig": "模板配置", + "templates": { + "title": "消息模板配置", + "templateType": "模板類型", + "templateContent": "模板內容", + "isDefault": "默認模板", + "isCustom": "自定義模板", + "resetToDefault": "重置為默認", + "resetConfirm": "確定要重置為默認模板嗎?您的自定義內容將丟失。", + "resetSuccess": "重置成功", + "resetFailed": "重置失敗", + "saveSuccess": "保存成功", + "saveFailed": "保存失敗", + "testSuccess": "測試消息發送成功,請檢查 Telegram", + "testFailed": "測試消息發送失敗", + "variables": "可用變量", + "clickToCopy": "點擊複製", + "copied": "已複製", + "commonVariables": "通用變量", + "orderVariables": "訂單變量", + "copyTradingVariables": "跟單變量", + "redeemVariables": "贖回變量", + "errorVariables": "錯誤變量", + "filterVariables": "過濾變量", + "strategyVariables": "策略變量", + "contentPlaceholder": "在此輸入模板內容,使用 {{變量名}} 插入動態內容", + "variableLabels": { + "account_name": "賬戶名稱", + "wallet_address": "錢包地址", + "time": "時間", + "order_id": "訂單ID", + "market_title": "市場標題", + "market_link": "市場鏈接", + "side": "方向", + "outcome": "市場方向", + "price": "價格", + "quantity": "數量", + "amount": "金額", + "available_balance": "可用餘額", + "leader_name": "Leader 名稱", + "config_name": "跟單配置名", + "error_message": "錯誤信息", + "filter_type": "過濾類型", + "filter_reason": "過濾原因", + "strategy_name": "策略名稱", + "transaction_hash": "交易哈希", + "total_value": "贖回總價值" + }, + "variableDescriptions": { + "account_name": "執行訂單的賬戶名稱", + "wallet_address": "錢包地址(已脫敏)", + "time": "通知發送時間", + "order_id": "訂單唯一標識", + "market_title": "市場/事件名稱", + "market_link": "Polymarket 市場鏈接", + "side": "訂單方向(買入/賣出)", + "outcome": "市場方向(YES/NO 等)", + "price": "訂單價格", + "quantity": "訂單數量(shares)", + "amount": "訂單金額(USDC)", + "available_balance": "賬戶可用餘額(USDC)", + "leader_name": "跟單的 Leader 名稱/備註", + "config_name": "跟單配置名稱", + "error_message": "訂單失敗原因", + "filter_type": "訂單被過濾的類型", + "filter_reason": "訂單被過濾的詳細原因", + "strategy_name": "加密價差策略名稱", + "transaction_hash": "贖回交易的哈希值", + "total_value": "贖回的總價值(USDC)" + } + }, + "templateTypes": { + "ORDER_SUCCESS": "訂單成功通知", + "ORDER_FAILED": "訂單失敗通知", + "ORDER_FILTERED": "訂單過濾通知", + "CRYPTO_TAIL_SUCCESS": "加密價差策略成功通知", + "REDEEM_SUCCESS": "倉位贖回成功通知", + "REDEEM_NO_RETURN": "倉位結算(無收益)通知" + }, + "templateTypeDescriptions": { + "ORDER_SUCCESS": "訂單創建成功時發送的通知", + "ORDER_FAILED": "訂單創建失敗時發送的通知", + "ORDER_FILTERED": "訂單被風控過濾時發送的通知", + "CRYPTO_TAIL_SUCCESS": "加密價差策略下單成功時發送的通知", + "REDEEM_SUCCESS": "倉位贖回成功時發送的通知", + "REDEEM_NO_RETURN": "倉位結算但無收益時發送的通知" + } }, "telegramConfig": { "title": "Telegram 配置說明", @@ -1351,6 +1444,8 @@ "title": "回測", "taskName": "任務名稱", "leader": "Leader", + "balance": "資金 (初始→最終)", + "profit": "收益 (金額/比例)", "initialBalance": "初始資金", "backtestDays": "回測天數", "status": "狀態", @@ -1399,6 +1494,7 @@ "createCopyTradingSuccess": "跟單配置創建成功", "noTasks": "暫無回測任務", "noTrades": "暫無交易記錄", + "noData": "暫無回測數據", "fetchTasksFailed": "獲取任務列表失敗", "fetchTaskDetailFailed": "獲取任務詳情失敗", "fetchTradesFailed": "獲取交易記錄失敗", @@ -1496,7 +1592,9 @@ "strategyName": "策略名稱", "account": "賬戶", "market": "關聯市場", + "marketAndTime": "市場/時間", "timeWindow": "時間區間", + "config": "配置", "priceRange": "價格區間", "amountMode": "投入方式", "ratio": "比例", @@ -1509,6 +1607,7 @@ "disable": "停用", "delete": "刪除", "viewTriggers": "訂單", + "viewPnlCurve": "收益曲線", "deleteConfirm": "確定刪除該策略?", "fetchFailed": "獲取列表失敗", "configGuide": "配置指南" @@ -1576,6 +1675,20 @@ "emptySuccess": "暫無成功記錄", "emptyFail": "暫無失敗記錄", "totalCount": "共 {count} 條" + }, + "pnlCurve": { + "title": "收益曲線", + "totalPnl": "總收益", + "settledCount": "已結算筆數", + "winRate": "勝率", + "maxDrawdown": "最大回撤", + "timeRange": "時間範圍", + "today": "今日", + "last7Days": "近7天", + "last30Days": "近30天", + "all": "全部", + "customRange": "自定義", + "empty": "暫無已結算訂單,無法展示收益曲線" } }, "cryptoTailMonitor": { @@ -1644,6 +1757,49 @@ "switchToLatest": "切換到最新週期", "periodEnded": "當前週期已結束", "newPeriodAvailable": "新週期已開始" + }, + "manualOrder": { + "title": "手動下單", + "buttonUp": "買入 Up", + "buttonDown": "買入 Down", + "confirmTitle": "手動下單確認", + "marketTitle": "市場標題", + "direction": "方向", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "下單價格", + "orderSize": "下單數量", + "totalAmount": "總金額", + "account": "賬戶", + "cancel": "取消", + "confirm": "確認下單", + "orderUnit": "USDC", + "sizeUnit": "張", + "statusNotOrdered": "未下單", + "statusOrdered": "本週期已下單", + "statusOrderedAuto": "本週期已自動下單", + "statusOrderedManual": "本週期已手動下單", + "errorInsufficientBalance": "賬戶餘額不足,可用餘額:{balance} USDC", + "errorPriceOutOfRange": "價格必須在 0~1 之間", + "errorMinSize": "數量不能少於 1 張", + "errorExceedsBalance": "總金額超過可用餘額", + "errorPriceRequired": "請輸入下單價格", + "errorSizeRequired": "請輸入下單數量", + "success": "手動下單成功", + "failed": "手動下單失敗:{{reason}}", + "errorSigning": "簽名失敗:{reason}", + "errorNetwork": "網絡錯誤,請稍後重試", + "priceNotLoaded": "價格數據未加載", + "insufficientBalance": "餘額不足,可用金額少於 1 USDC", + "fetchBalanceFailed": "獲取賬戶餘額失敗", + "availableBalance": "可用餘額", + "fetchLatestPrice": "獲取最新價", + "priceUpdated": "價格已更新", + "maxSize": "最大", + "invalidPriceOrBalance": "價格或餘額無效", + "insufficientBalanceForMax": "餘額不足以購買最大數量", + "maxSizeUpdated": "已更新為最大數量", + "periodChanged": "週期已切換,彈窗已關閉" } } } \ No newline at end of file diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index 0ed36933..e9b5f7f2 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd' -import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd' +import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useAccountStore } from '../store/accountStore' import type { Account } from '../types' @@ -331,151 +331,49 @@ const AccountList: React.FC = () => { { title: t('accountList.action'), key: 'action', + width: 140, render: (_: any, record: Account) => ( - <Space size="small"> - <Button - type="link" - size="small" - onClick={() => handleShowDetail(record)} - > - {t('accountList.detail')} - </Button> - <Button - type="link" - size="small" - icon={<EditOutlined />} - onClick={() => handleShowEdit(record)} - > - {t('accountList.edit')} - </Button> - <Popconfirm - title={t('accountList.deleteConfirm')} - description={ - record.apiKeyConfigured - ? t('accountList.deleteConfirmDesc') - : t('accountList.deleteConfirmDescSimple') - } - onConfirm={() => handleDelete(record)} - okText={t('accountList.deleteConfirmOk')} - cancelText={t('common.cancel')} - okButtonProps={{ danger: true }} - > - <Button type="link" size="small" danger> - {t('accountList.delete')} - </Button> - </Popconfirm> - </Space> - ) - } - ] - - const mobileColumns = [ - { - title: t('accountList.accountName'), - key: 'info', - render: (_: any, record: Account) => { - return ( - <div style={{ padding: '8px 0' }}> - <div style={{ - fontWeight: 'bold', - marginBottom: '8px', - fontSize: '16px' - }}> - {record.accountName || `${t('accountList.accountName')} ${record.id}`} - </div> - <div style={{ - fontSize: '11px', - color: '#666', - marginBottom: '8px', - wordBreak: 'break-all', - fontFamily: 'monospace', - lineHeight: '1.4' - }}> - <div style={{ marginBottom: '4px' }}> - <strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'} - <Button - type="text" - size="small" - icon={<CopyOutlined />} - onClick={(e) => { - e.stopPropagation() - handleCopy(record.walletAddress) - }} - style={{ marginLeft: '4px', padding: '0 4px' }} - /> - </div> - <div style={{ marginBottom: '4px' }}> - <strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'} - <Button - type="text" - size="small" - icon={<CopyOutlined />} - onClick={(e) => { - e.stopPropagation() - handleCopy(record.proxyAddress) - }} - style={{ marginLeft: '4px', padding: '0 4px' }} - /> - </div> - {record.walletType && ( - <div style={{ marginBottom: '4px' }}> - <strong>{t('accountList.walletType')}:</strong>{' '} - <Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}> - {record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'} - </Tag> - </div> - )} + <Space size={4}> + <Tooltip title={t('accountList.detail')}> + <div + onClick={() => handleShowDetail(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} /> </div> - <div style={{ - fontSize: '14px', - fontWeight: '500', - color: '#1890ff' - }}> - {t('accountList.totalBalance')}: {balanceLoading[record.id] ? ( - <Spin size="small" style={{ marginLeft: '4px' }} /> - ) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? ( - `${formatUSDC(balanceMap[record.id].total)} USDC` - ) : ( - '-' - )} + </Tooltip> + + <Tooltip title={t('accountList.edit')}> + <div + onClick={() => handleShowEdit(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} /> </div> - {balanceMap[record.id] && balanceMap[record.id].available !== '-' && ( - <div style={{ - fontSize: '12px', - color: '#666', - marginTop: '4px' - }}> - {t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC - </div> - )} - </div> - ) - } - }, - { - title: t('accountList.action'), - key: 'action', - width: 100, - render: (_: any, record: Account) => ( - <Space direction="vertical" size="small" style={{ width: '100%' }}> - <Button - type="primary" - size="small" - block - onClick={() => handleShowDetail(record)} - style={{ minHeight: '32px' }} - > - {t('accountList.viewDetail')} - </Button> - <Button - size="small" - block - icon={<EditOutlined />} - onClick={() => handleShowEdit(record)} - style={{ minHeight: '32px' }} - > - {t('accountList.edit')} - </Button> + </Tooltip> + <Popconfirm title={t('accountList.deleteConfirm')} description={ @@ -488,14 +386,24 @@ const AccountList: React.FC = () => { cancelText={t('common.cancel')} okButtonProps={{ danger: true }} > - <Button - size="small" - block - danger - style={{ minHeight: '32px' }} - > - {t('accountList.delete')} - </Button> + <Tooltip title={t('accountList.delete')}> + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} /> + </div> + </Tooltip> </Popconfirm> </Space> ) @@ -519,16 +427,15 @@ const AccountList: React.FC = () => { <Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}> {t('accountList.title')} - + +