From 777710c2ed67f3a8bdd8b3b9c1b408a34246bbc9 Mon Sep 17 00:00:00 2001 From: WrBug Date: Fri, 5 Dec 2025 02:20:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Telegram=20?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 推送模板优化: - 优先使用账户名称而不是钱包地址 - 使用市场标题而不是16进制ID - 添加可点击的市场链接(支持 slug 和 conditionId) - 添加市场方向(outcome)显示 - 数量和价格从订单详情API获取实际值 - 失败通知只显示后端返回的msg,不显示完整堆栈 - 多语言支持: - 后端推送消息支持多语言(使用前端最后请求的语言) - 添加所有 ErrorCode 的多语言资源文件(中文简体、繁体、英文) - 通知消息文本全部使用多语言资源 - 功能改进: - 从订单详情获取实际的 side、price、size、outcome - 支持买入/卖出方向的多语言显示 - 错误信息优化,只显示后端返回的错误消息 --- .cursor/rules/backend.mdc | 119 ++++ .cursor/rules/frontend.mdc | 46 ++ .../controller/NotificationController.kt | 386 +++++++++++ .../dto/NotificationConfigDto.kt | 51 ++ .../entity/NotificationConfig.kt | 34 + .../wrbug/polymarketbot/enums/ErrorCode.kt | 35 +- .../NotificationConfigRepository.kt | 13 + .../polymarketbot/service/AccountService.kt | 160 ++++- .../service/CopyOrderTrackingService.kt | 130 +++- .../service/NotificationConfigService.kt | 236 +++++++ .../service/TelegramNotificationService.kt | 636 ++++++++++++++++++ .../V3__create_notification_configs.sql | 39 ++ .../resources/i18n/messages_en.properties | 231 +++++++ .../resources/i18n/messages_zh_CN.properties | 231 +++++++ .../resources/i18n/messages_zh_TW.properties | 231 +++++++ docs/telegram-bot-setup.md | 98 +++ docs/telegram-notification-implementation.md | 166 +++++ frontend/index.html | 2 +- frontend/public/logo.svg | 59 ++ frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 16 +- frontend/src/components/Logo.tsx | 170 +++++ .../notifications/DiscordConfigForm.tsx | 49 ++ .../notifications/SlackConfigForm.tsx | 49 ++ .../notifications/TelegramConfigForm.tsx | 123 ++++ .../src/components/notifications/index.ts | 4 + frontend/src/locales/en/common.json | 71 ++ frontend/src/locales/zh-CN/common.json | 115 ++++ frontend/src/locales/zh-TW/common.json | 71 ++ frontend/src/pages/NotificationSettings.tsx | 406 +++++++++++ frontend/src/services/api.ts | 55 +- frontend/src/types/index.ts | 46 ++ 32 files changed, 4059 insertions(+), 21 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/NotificationController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationConfigDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationConfig.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationConfigRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/NotificationConfigService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt create mode 100644 backend/src/main/resources/db/migration/V3__create_notification_configs.sql create mode 100644 docs/telegram-bot-setup.md create mode 100644 docs/telegram-notification-implementation.md create mode 100644 frontend/public/logo.svg create mode 100644 frontend/src/components/Logo.tsx create mode 100644 frontend/src/components/notifications/DiscordConfigForm.tsx create mode 100644 frontend/src/components/notifications/SlackConfigForm.tsx create mode 100644 frontend/src/components/notifications/TelegramConfigForm.tsx create mode 100644 frontend/src/components/notifications/index.ts create mode 100644 frontend/src/pages/NotificationSettings.tsx diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc index ae89f24..d9934c8 100644 --- a/.cursor/rules/backend.mdc +++ b/.cursor/rules/backend.mdc @@ -535,3 +535,122 @@ if (targetOutcomeIndex >= 0) { // 使用 targetOutcomeIndex 进行判断 } ``` + +## 多语言使用规范 + +### 错误消息和响应文本 +- **禁止**在代码中硬编码中文或英文错误消息 +- **必须**使用 `ErrorCode` 枚举定义错误码和消息 +- **必须**使用 `ApiResponse.error(ErrorCode, messageSource)` 或 `MessageUtils.getMessage()` 获取国际化消息 +- **禁止**直接使用 `ApiResponse.paramError("硬编码消息")` 或 `ApiResponse.serverError("硬编码消息")` +- 错误消息的默认语言使用中文(在 `ErrorCode` 枚举中定义),通过 `MessageSource` 支持多语言 + +### 使用 ErrorCode 和 MessageSource +项目已经实现了国际化支持,必须使用以下方式: + +```kotlin +// ❌ 错误:硬编码错误消息 +return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空")) +return ResponseEntity.ok(ApiResponse.serverError("获取配置列表失败:${e.message}")) + +// ✅ 正确:使用 ErrorCode 枚举 +return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource = messageSource)) + +// ✅ 正确:使用 ErrorCode 和自定义消息(如果需要动态消息) +return ResponseEntity.ok(ApiResponse.error( + ErrorCode.PARAM_ERROR, + customMsg = "配置ID不能为空", + messageSource = messageSource +)) + +// ✅ 正确:使用 MessageUtils +@Autowired +private lateinit var messageUtils: MessageUtils + +return ResponseEntity.ok(ApiResponse.error( + ErrorCode.PARAM_EMPTY, + messageSource = messageSource +)) +``` + +### 添加新的错误码 +如果需要添加新的错误码,必须在 `ErrorCode` 枚举中定义: + +```kotlin +enum class ErrorCode( + val code: Int, + val message: String, // 默认消息(中文) + val messageKey: String // 国际化消息键 +) { + // 新错误码示例 + NOTIFICATION_CONFIG_NOT_FOUND(3009, "通知配置不存在", "error.notification_config_not_found"), + NOTIFICATION_CONFIG_INVALID(4009, "通知配置无效", "error.notification_config_invalid"), +} +``` + +然后在语言资源文件中添加对应的翻译: +- `src/main/resources/messages_zh_CN.properties` +- `src/main/resources/messages_zh_TW.properties` +- `src/main/resources/messages_en.properties` + +### 日志消息规范 +- **日志消息可以使用中文或英文**,便于开发调试 +- **禁止**在日志中硬编码用户可见的错误消息(应该使用 ErrorCode) +- 日志消息应该清晰、简洁,便于排查问题 +- 日志中的业务数据(如账户名、订单ID等)可以使用原始值 + +### 代码注释规范 +- **代码注释可以使用中文或英文** +- **业务逻辑注释**建议使用中文,便于团队理解 +- **API 文档注释**(KDoc)建议使用中文,但也可以使用英文 +- **类和方法注释**应该清晰说明功能和参数 + +### 数据库字段和配置 +- **数据库字段名**使用英文(snake_case) +- **配置项名称**使用英文(kebab-case 或 dot.notation) +- **枚举值**使用英文(UPPER_SNAKE_CASE) +- **常量定义**使用英文(UPPER_SNAKE_CASE) + +### 多语言支持策略 +1. **API 响应消息**:使用 `ErrorCode` 枚举,通过 `MessageSource` 支持多语言 +2. **错误码**:使用 `ErrorCode` 枚举,包含 `code`、`message`(默认中文)和 `messageKey`(国际化键) +3. **日志消息**:可以使用中文或英文,便于开发调试 +4. **代码注释**:可以使用中文或英文,建议使用中文 +5. **业务数据**:根据实际需求,可以包含多语言内容(如市场标题、描述等) + +### Controller 中使用多语言示例 + +```kotlin +@RestController +class NotificationController( + private val notificationConfigService: NotificationConfigService, + private val messageSource: MessageSource // 注入 MessageSource +) { + @PostMapping("/configs/list") + fun list(@RequestBody request: NotificationConfigListRequest): ResponseEntity>> { + return try { + // ... 业务逻辑 + ResponseEntity.ok(ApiResponse.success(configs)) + } catch (e: Exception) { + logger.error("获取配置列表失败: ${e.message}", e) + // ✅ 正确:使用 ErrorCode 和 MessageSource + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + messageSource = messageSource + )) + } + } + + @PostMapping("/configs/detail") + fun detail(@RequestBody request: NotificationConfigDetailRequest): ResponseEntity> { + if (request.id == null) { + // ✅ 正确:使用 ErrorCode + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.PARAM_EMPTY, + messageSource = messageSource + )) + } + // ... 业务逻辑 + } +} +``` diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index 8929b54..47a0e94 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -330,3 +330,49 @@ const ResponsiveTable: React.FC = () => { - ❌ 禁止使用过小的触摸目标(< 44x44px) - ❌ 禁止忽略移动端性能优化 - ❌ 禁止使用桌面端专用的交互方式(如 hover) + +### 多语言使用规范 +- **必须**使用多语言(i18n)进行所有文本显示 +- **禁止**在组件中硬编码中文或英文文本 +- **必须**使用 `useTranslation` Hook 获取翻译函数 +- **必须**在语言文件中定义所有显示的文本 +- **必须**支持至少中文(简体/繁体)和英文 + +#### 多语言使用示例 + +```typescript +// ✅ 正确:使用多语言 +import { useTranslation } from 'react-i18next' + +const MyComponent: React.FC = () => { + const { t } = useTranslation() + + return ( +
+

{t('page.title')}

+ +
+ ) +} + +// ❌ 错误:硬编码文本 +const MyComponent: React.FC = () => { + return ( +
+

页面标题

{/* 禁止硬编码 */} + {/* 禁止硬编码 */} +
+ ) +} +``` + +#### 语言文件结构 +- 语言文件位置:`src/locales/{locale}/common.json` +- 支持的语言:`zh-CN`(简体中文)、`zh-TW`(繁体中文)、`en`(英文) +- 键名使用点号分隔的层级结构,如:`notificationSettings.title` + +#### 添加新翻译的步骤 +1. 在 `src/locales/zh-CN/common.json` 中添加中文翻译 +2. 在 `src/locales/zh-TW/common.json` 中添加繁体中文翻译 +3. 在 `src/locales/en/common.json` 中添加英文翻译 +4. 在组件中使用 `t('key')` 获取翻译 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/NotificationController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/NotificationController.kt new file mode 100644 index 0000000..e0d991d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/NotificationController.kt @@ -0,0 +1,386 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.NotificationConfigService +import com.wrbug.polymarketbot.service.TelegramNotificationService +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 消息推送配置控制器 + */ +@RestController +@RequestMapping("/api/notifications") +class NotificationController( + private val notificationConfigService: NotificationConfigService, + private val telegramNotificationService: TelegramNotificationService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(NotificationController::class.java) + + /** + * 获取所有配置 + */ + @PostMapping("/configs/list") + fun getConfigs(@RequestBody request: NotificationConfigListRequest?): ResponseEntity>> { + return try { + val configs = runBlocking { + if (request?.type != null) { + notificationConfigService.getConfigsByType(request.type) + } else { + notificationConfigService.getAllConfigs() + } + } + ResponseEntity.ok(ApiResponse.success(configs)) + } catch (e: Exception) { + logger.error("获取通知配置列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_FETCH_FAILED, + customMsg = "获取配置列表失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 获取配置详情 + */ + @PostMapping("/configs/detail") + fun getConfigDetail(@RequestBody request: NotificationConfigDetailRequest): ResponseEntity> { + return try { + if (request.id == null) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_ID_EMPTY, + messageSource = messageSource + )) + } + + val config = runBlocking { + notificationConfigService.getConfigById(request.id) + } + + if (config == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(config)) + } + } catch (e: Exception) { + logger.error("获取通知配置详情失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_FETCH_FAILED, + customMsg = "获取配置详情失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 创建配置 + */ + @PostMapping("/configs/create") + fun createConfig(@RequestBody request: NotificationConfigRequest): ResponseEntity> { + return try { + if (request.type.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("推送类型不能为空")) + } + if (request.name.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("配置名称不能为空")) + } + if (request.config.isEmpty()) { + return ResponseEntity.ok(ApiResponse.paramError("配置信息不能为空")) + } + + val result = runBlocking { + notificationConfigService.createConfig(request) + } + + result.fold( + onSuccess = { config -> + ResponseEntity.ok(ApiResponse.success(config)) + }, + onFailure = { e -> + logger.error("创建通知配置失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_CREATE_FAILED, + customMsg = "创建配置失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("创建通知配置异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_CREATE_FAILED, + customMsg = "创建配置失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 更新配置 + */ + @PostMapping("/configs/update") + fun updateConfig(@RequestBody request: NotificationConfigUpdateRequest): ResponseEntity> { + return try { + if (request.id == null) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_ID_EMPTY, + messageSource = messageSource + )) + } + if (request.type.isBlank()) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_TYPE_EMPTY, + messageSource = messageSource + )) + } + if (request.name.isBlank()) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_NAME_EMPTY, + messageSource = messageSource + )) + } + if (request.config.isEmpty()) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_DATA_EMPTY, + messageSource = messageSource + )) + } + + val configRequest = NotificationConfigRequest( + type = request.type, + name = request.name, + enabled = request.enabled, + config = request.config + ) + + val result = runBlocking { + notificationConfigService.updateConfig(request.id, configRequest) + } + + result.fold( + onSuccess = { config -> + ResponseEntity.ok(ApiResponse.success(config)) + }, + onFailure = { e -> + logger.error("更新通知配置失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_UPDATE_FAILED, + customMsg = "更新配置失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("更新通知配置异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_UPDATE_FAILED, + customMsg = "更新配置失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 更新启用状态 + */ + @PostMapping("/configs/update-enabled") + fun updateEnabled(@RequestBody request: NotificationConfigUpdateEnabledRequest): ResponseEntity> { + return try { + if (request.id == null) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_ID_EMPTY, + messageSource = messageSource + )) + } + + val result = runBlocking { + notificationConfigService.updateEnabled(request.id, request.enabled ?: true) + } + + result.fold( + onSuccess = { config -> + ResponseEntity.ok(ApiResponse.success(config)) + }, + onFailure = { e -> + logger.error("更新通知配置启用状态失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_UPDATE_ENABLED_FAILED, + customMsg = "更新启用状态失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("更新通知配置启用状态异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_UPDATE_ENABLED_FAILED, + customMsg = "更新启用状态失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 删除配置 + */ + @PostMapping("/configs/delete") + fun deleteConfig(@RequestBody request: NotificationConfigDeleteRequest): ResponseEntity> { + return try { + if (request.id == null) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_ID_EMPTY, + messageSource = messageSource + )) + } + + val result = runBlocking { + notificationConfigService.deleteConfig(request.id) + } + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除通知配置失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_DELETE_FAILED, + customMsg = "删除配置失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("删除通知配置异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_DELETE_FAILED, + customMsg = "删除配置失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 测试通知 + */ + @PostMapping("/test") + fun testNotification(@RequestBody request: TestNotificationRequest?): ResponseEntity> { + return try { + val message = request?.message ?: "这是一条测试消息" + val success = runBlocking { + telegramNotificationService.sendTestMessage(message) + } + + 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 + )) + } + } + + /** + * 获取 Telegram Chat IDs + */ + @PostMapping("/telegram/get-chat-ids") + fun getTelegramChatIds(@RequestBody request: GetTelegramChatIdsRequest): ResponseEntity>> { + return try { + if (request.botToken.isBlank()) { + return ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_CONFIG_BOT_TOKEN_EMPTY, + messageSource = messageSource + )) + } + + val result = runBlocking { + telegramNotificationService.getChatIds(request.botToken) + } + + result.fold( + onSuccess = { chatIds -> + ResponseEntity.ok(ApiResponse.success(chatIds)) + }, + onFailure = { e -> + logger.error("获取 Chat IDs 失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_GET_CHAT_IDS_FAILED, + customMsg = "获取 Chat IDs 失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("获取 Chat IDs 异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_GET_CHAT_IDS_FAILED, + customMsg = "获取 Chat IDs 失败:${e.message}", + messageSource = messageSource + )) + } + } +} + +/** + * 获取 Telegram Chat IDs 请求 + */ +data class GetTelegramChatIdsRequest( + val botToken: String +) + +/** + * 配置列表请求 + */ +data class NotificationConfigListRequest( + val type: String? = null // 可选,按类型筛选 +) + +/** + * 配置详情请求 + */ +data class NotificationConfigDetailRequest( + val id: Long +) + +/** + * 配置更新请求 + */ +data class NotificationConfigUpdateRequest( + val id: Long, + val type: String, + val name: String, + val enabled: Boolean? = null, + val config: Map +) + +/** + * 更新启用状态请求 + */ +data class NotificationConfigUpdateEnabledRequest( + val id: Long, + val enabled: Boolean? = true +) + +/** + * 删除配置请求 + */ +data class NotificationConfigDeleteRequest( + val id: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationConfigDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationConfigDto.kt new file mode 100644 index 0000000..44d674a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationConfigDto.kt @@ -0,0 +1,51 @@ +package com.wrbug.polymarketbot.dto + +/** + * 消息推送配置 DTO + */ +data class NotificationConfigDto( + val id: Long? = null, + val type: String, // telegram、discord、slack 等 + val name: String, // 配置名称 + val enabled: Boolean, // 是否启用 + val config: NotificationConfigData, // 配置信息(根据类型不同而不同) + val createdAt: Long? = null, + val updatedAt: Long? = null +) + +/** + * Telegram 配置数据 + */ +data class TelegramConfigData( + val botToken: String, + val chatIds: List // 多个 Chat ID,逗号分隔或数组 +) + +/** + * 通用配置数据(用于未来扩展) + */ +sealed class NotificationConfigData { + data class Telegram(val data: TelegramConfigData) : NotificationConfigData() + // 未来可以添加其他类型 + // data class Discord(val data: DiscordConfigData) : NotificationConfigData() + // data class Slack(val data: SlackConfigData) : NotificationConfigData() +} + +/** + * 创建/更新配置请求 + */ +data class NotificationConfigRequest( + val type: String, + val name: String, + val enabled: Boolean? = true, + val config: Map // 配置信息(JSON 对象) +) + +/** + * 测试通知请求 + */ +data class TestNotificationRequest( + val configId: Long? = null, // 如果提供,使用指定配置;否则使用所有启用的配置 + val message: String? = null // 测试消息内容 +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationConfig.kt new file mode 100644 index 0000000..089703f --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationConfig.kt @@ -0,0 +1,34 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 消息推送配置实体 + * 支持多种推送方式(Telegram、Discord、Slack 等) + */ +@Entity +@Table(name = "notification_configs") +data class NotificationConfig( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "type", nullable = false, length = 50) + val type: String, // telegram、discord、slack 等 + + @Column(name = "name", nullable = false, length = 100) + val name: String, // 配置名称(用于显示) + + @Column(name = "enabled", nullable = false) + var enabled: Boolean = true, // 是否启用 + + @Column(name = "config_json", nullable = false, columnDefinition = "TEXT") + val configJson: String, // 配置信息(JSON格式) + + @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/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index eecaa69..bfb3dcc 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -132,19 +132,34 @@ enum class ErrorCode( POSITION_INSUFFICIENT(4503, "仓位不足", "error.position_insufficient"), POSITION_ALREADY_REDEEMED(4504, "仓位已赎回", "error.position_already_redeemed"), + // 通知配置相关 (4601-4699) + NOTIFICATION_CONFIG_NOT_FOUND(4601, "通知配置不存在", "error.notification_config_not_found"), + NOTIFICATION_CONFIG_ID_EMPTY(4602, "配置ID不能为空", "error.notification_config_id_empty"), + NOTIFICATION_CONFIG_TYPE_EMPTY(4603, "推送类型不能为空", "error.notification_config_type_empty"), + NOTIFICATION_CONFIG_NAME_EMPTY(4604, "配置名称不能为空", "error.notification_config_name_empty"), + NOTIFICATION_CONFIG_DATA_EMPTY(4605, "配置信息不能为空", "error.notification_config_data_empty"), + NOTIFICATION_CONFIG_BOT_TOKEN_EMPTY(4606, "Bot Token 不能为空", "error.notification_config_bot_token_empty"), + NOTIFICATION_CONFIG_CREATE_FAILED(4607, "创建配置失败", "error.notification_config_create_failed"), + NOTIFICATION_CONFIG_UPDATE_FAILED(4608, "更新配置失败", "error.notification_config_update_failed"), + NOTIFICATION_CONFIG_DELETE_FAILED(4609, "删除配置失败", "error.notification_config_delete_failed"), + NOTIFICATION_CONFIG_UPDATE_ENABLED_FAILED(4610, "更新启用状态失败", "error.notification_config_update_enabled_failed"), + NOTIFICATION_CONFIG_FETCH_FAILED(4611, "获取配置失败", "error.notification_config_fetch_failed"), + NOTIFICATION_TEST_FAILED(4612, "发送测试消息失败,请检查配置", "error.notification_test_failed"), + NOTIFICATION_GET_CHAT_IDS_FAILED(4613, "获取 Chat IDs 失败", "error.notification_get_chat_ids_failed"), + // 账户相关业务错误 (4601-4699) ACCOUNT_ALREADY_EXISTS(4601, "账户已存在", "error.account_already_exists"), - ACCOUNT_IS_DEFAULT(4602, "账户已是默认账户", "error.account_is_default"), - ACCOUNT_HAS_ACTIVE_ORDERS(4603, "账户有活跃订单", "error.account_has_active_orders"), - ACCOUNT_IS_LAST_ONE(4604, "不能删除最后一个账户", "error.account_is_last_one"), - ACCOUNT_API_KEY_CREATE_FAILED(4605, "自动获取 API Key 失败", "error.account_api_key_create_failed"), - ACCOUNT_PROXY_ADDRESS_FETCH_FAILED(4606, "获取代理地址失败", "error.account_proxy_address_fetch_failed"), - ACCOUNT_BALANCE_FETCH_FAILED(4607, "查询账户余额失败", "error.account_balance_fetch_failed"), - ACCOUNT_POSITIONS_FETCH_FAILED(4608, "查询仓位列表失败", "error.account_positions_fetch_failed"), + ACCOUNT_IS_DEFAULT(4702, "账户已是默认账户", "error.account_is_default"), + ACCOUNT_HAS_ACTIVE_ORDERS(4703, "账户有活跃订单", "error.account_has_active_orders"), + ACCOUNT_IS_LAST_ONE(4704, "不能删除最后一个账户", "error.account_is_last_one"), + ACCOUNT_API_KEY_CREATE_FAILED(4705, "自动获取 API Key 失败", "error.account_api_key_create_failed"), + ACCOUNT_PROXY_ADDRESS_FETCH_FAILED(4706, "获取代理地址失败", "error.account_proxy_address_fetch_failed"), + ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"), + ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"), - // 统计相关 (4701-4799) - STATISTICS_FETCH_FAILED(4701, "获取统计信息失败", "error.statistics_fetch_failed"), - ORDER_LIST_FETCH_FAILED(4702, "查询订单列表失败", "error.order_list_fetch_failed"), + // 统计相关 (4801-4899) + STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"), + ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"), // ==================== 服务器内部错误 (5001-5999) ==================== SERVER_ERROR(5001, "服务器内部错误", "error.server.error"), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationConfigRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationConfigRepository.kt new file mode 100644 index 0000000..5b9696d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationConfigRepository.kt @@ -0,0 +1,13 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.NotificationConfig +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface NotificationConfigRepository : JpaRepository { + fun findByType(type: String): List + fun findByTypeAndEnabled(type: String, enabled: Boolean): List + fun findByIdAndType(id: Long, type: String): NotificationConfig? +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index fb362a3..ff98215 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -8,7 +8,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.eq import com.wrbug.polymarketbot.util.JsonUtils -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -27,10 +27,14 @@ class AccountService( private val apiKeyService: PolymarketApiKeyService, private val orderPushService: OrderPushService, private val orderSigningService: OrderSigningService, - private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils + private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, + private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖 ) { private val logger = LoggerFactory.getLogger(AccountService::class.java) + + // 协程作用域(用于异步发送通知) + private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // 市价单价格调整系数(在最优价基础上调整,确保更快成交) // 市价买单:bestAsk + BUY_PRICE_ADJUSTMENT(加价,确保能立即成交) @@ -859,9 +863,60 @@ class AccountService( if (orderResponse.isSuccessful && orderResponse.body() != null) { val response = orderResponse.body()!! if (response.success) { + val orderId = response.orderId ?: "" + + // 发送订单成功通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取市场信息(标题和slug) + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: request.marketId + val marketSlug = marketInfo?.slug + + // 获取当前语言设置(从 LocaleContextHolder) + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + telegramNotificationService?.sendOrderSuccessNotification( + orderId = orderId, + marketTitle = marketTitle, + marketId = request.marketId, + marketSlug = marketSlug, + side = request.side, + accountName = account.accountName, + walletAddress = account.walletAddress, + clobApi = clobApi, + apiKey = account.apiKey, + apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null }, + apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null }, + walletAddressForApi = account.walletAddress, + locale = locale + ) + } catch (e: Exception) { + logger.warn("发送订单成功通知失败: ${e.message}", e) + } + } + Result.success( PositionSellResponse( - orderId = response.orderId ?: "", + orderId = orderId, marketId = request.marketId, side = request.side, orderType = request.orderType, @@ -875,6 +930,54 @@ class AccountService( val errorMsg = response.errorMsg ?: "未知错误" 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) + + // 发送订单失败通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取市场信息(标题和slug) + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: request.marketId + val marketSlug = marketInfo?.slug + + // 获取当前语言设置(从 LocaleContextHolder) + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + telegramNotificationService?.sendOrderFailureNotification( + marketTitle = marketTitle, + marketId = request.marketId, + marketSlug = marketSlug, + side = request.side, + outcome = null, // 失败时可能没有 outcome + price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET", + size = sellQuantity.toString(), + errorMessage = errorMsg, // 只传递后端返回的 msg + accountName = account.accountName, + walletAddress = account.walletAddress, + locale = locale + ) + } catch (e: Exception) { + logger.warn("发送订单失败通知失败: ${e.message}", e) + } + } + Result.failure(Exception(fullErrorMsg)) } } else { @@ -885,6 +988,57 @@ class AccountService( } 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) + + // 发送订单失败通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取市场信息(标题和slug) + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: request.marketId + val marketSlug = marketInfo?.slug + + // 获取当前语言设置(从 LocaleContextHolder) + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + // 只传递后端返回的 msg,不传递完整堆栈 + val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败" + + telegramNotificationService?.sendOrderFailureNotification( + marketTitle = marketTitle, + marketId = request.marketId, + marketSlug = marketSlug, + side = request.side, + outcome = null, // 失败时可能没有 outcome + price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET", + size = sellQuantity.toString(), + errorMessage = errorMsg, // 只传递后端返回的 msg + accountName = account.accountName, + walletAddress = account.walletAddress, + locale = locale + ) + } catch (e: Exception) { + logger.warn("发送订单失败通知失败: ${e.message}", e) + } + } + Result.failure(Exception(fullErrorMsg)) } } catch (e: Exception) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt index 54be1cd..16485f0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt @@ -7,7 +7,7 @@ import com.wrbug.polymarketbot.entity.* import com.wrbug.polymarketbot.repository.* import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.* -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service @@ -34,11 +34,15 @@ class CopyOrderTrackingService( private val orderSigningService: OrderSigningService, private val blockchainService: BlockchainService, private val retrofitFactory: RetrofitFactory, - private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils + private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, + private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖 ) { private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java) + // 协程作用域(用于异步发送通知) + private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + /** * 解密账户私钥 */ @@ -301,6 +305,54 @@ class CopyOrderTrackingService( errorMessage = errorMsg, retryCount = 1 // 已重试一次 ) + + // 发送订单失败通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取市场信息(标题和slug) + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: trade.market + val marketSlug = marketInfo?.slug + + // 获取当前语言设置(从 LocaleContextHolder) + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + telegramNotificationService?.sendOrderFailureNotification( + marketTitle = marketTitle, + marketId = trade.market, + marketSlug = marketSlug, + side = "BUY", + outcome = null, // 失败时可能没有 outcome + price = buyPrice.toString(), + size = finalBuyQuantity.toString(), + errorMessage = errorMsg, // 只传递后端返回的 msg + accountName = account.accountName, + walletAddress = account.walletAddress, + locale = locale + ) + } catch (e: Exception) { + logger.warn("发送订单失败通知失败: ${e.message}", e) + } + } + continue } @@ -324,6 +376,80 @@ class CopyOrderTrackingService( ) copyOrderTrackingRepository.save(tracking) + + // 发送订单成功通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取市场信息(标题和slug) + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: trade.market + val marketSlug = marketInfo?.slug + + // 重新创建 CLOB API 客户端用于查询订单详情 + val apiSecret = try { + decryptApiSecret(account) + } catch (e: Exception) { + logger.warn("解密 API Secret 失败: ${e.message}", e) + null + } + val apiPassphrase = try { + decryptApiPassphrase(account) + } catch (e: Exception) { + logger.warn("解密 API Passphrase 失败: ${e.message}", e) + null + } + + val clobApiForQuery = if (account.apiKey != null && apiSecret != null && apiPassphrase != null) { + retrofitFactory.createClobApi( + account.apiKey!!, + apiSecret, + apiPassphrase, + account.walletAddress + ) + } else { + null + } + + // 获取当前语言设置(从 LocaleContextHolder) + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + telegramNotificationService?.sendOrderSuccessNotification( + orderId = realOrderId, + marketTitle = marketTitle, + marketId = trade.market, + marketSlug = marketSlug, + side = "BUY", + accountName = account.accountName, + walletAddress = account.walletAddress, + clobApi = clobApiForQuery, + apiKey = account.apiKey, + apiSecret = apiSecret, + apiPassphrase = apiPassphrase, + walletAddressForApi = account.walletAddress, + locale = locale + ) + } catch (e: Exception) { + logger.warn("发送订单成功通知失败: ${e.message}", e) + } + } } catch (e: Exception) { logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e) // 继续处理下一个跟单关系 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/NotificationConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/NotificationConfigService.kt new file mode 100644 index 0000000..90699e6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/NotificationConfigService.kt @@ -0,0 +1,236 @@ +package com.wrbug.polymarketbot.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.NotificationConfig +import com.wrbug.polymarketbot.repository.NotificationConfigRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 消息推送配置服务 + */ +@Service +class NotificationConfigService( + private val notificationConfigRepository: NotificationConfigRepository, + private val objectMapper: ObjectMapper +) { + + private val logger = LoggerFactory.getLogger(NotificationConfigService::class.java) + + /** + * 获取所有配置 + */ + suspend fun getAllConfigs(): List { + return withContext(Dispatchers.IO) { + notificationConfigRepository.findAll().map { entityToDto(it) } + } + } + + /** + * 根据类型获取配置 + */ + suspend fun getConfigsByType(type: String): List { + return withContext(Dispatchers.IO) { + notificationConfigRepository.findByType(type).map { entityToDto(it) } + } + } + + /** + * 获取启用的配置(按类型) + */ + suspend fun getEnabledConfigsByType(type: String): List { + return withContext(Dispatchers.IO) { + notificationConfigRepository.findByTypeAndEnabled(type, true).map { entityToDto(it) } + } + } + + /** + * 根据 ID 获取配置 + */ + suspend fun getConfigById(id: Long): NotificationConfigDto? { + return withContext(Dispatchers.IO) { + notificationConfigRepository.findById(id).orElse(null)?.let { entityToDto(it) } + } + } + + /** + * 创建配置 + */ + @Transactional + suspend fun createConfig(request: NotificationConfigRequest): Result { + return try { + // 验证配置数据 + validateConfig(request.type, request.config) + + val configJson = objectMapper.writeValueAsString(request.config) + val config = NotificationConfig( + type = request.type, + name = request.name, + enabled = request.enabled ?: true, + configJson = configJson + ) + + val saved = withContext(Dispatchers.IO) { + notificationConfigRepository.save(config) + } + + Result.success(entityToDto(saved)) + } catch (e: Exception) { + logger.error("创建通知配置失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 更新配置 + */ + @Transactional + suspend fun updateConfig(id: Long, request: NotificationConfigRequest): Result { + return try { + val existing = withContext(Dispatchers.IO) { + notificationConfigRepository.findById(id).orElse(null) + } ?: return Result.failure(IllegalArgumentException("配置不存在")) + + // 验证配置数据 + validateConfig(request.type, request.config) + + val configJson = objectMapper.writeValueAsString(request.config) + val updated = existing.copy( + type = request.type, + name = request.name, + enabled = request.enabled ?: existing.enabled, + configJson = configJson, + updatedAt = System.currentTimeMillis() + ) + + val saved = withContext(Dispatchers.IO) { + notificationConfigRepository.save(updated) + } + + Result.success(entityToDto(saved)) + } catch (e: Exception) { + logger.error("更新通知配置失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 更新启用状态 + */ + @Transactional + suspend fun updateEnabled(id: Long, enabled: Boolean): Result { + return try { + val existing = withContext(Dispatchers.IO) { + notificationConfigRepository.findById(id).orElse(null) + } ?: return Result.failure(IllegalArgumentException("配置不存在")) + + val updated = existing.copy( + enabled = enabled, + updatedAt = System.currentTimeMillis() + ) + + val saved = withContext(Dispatchers.IO) { + notificationConfigRepository.save(updated) + } + + Result.success(entityToDto(saved)) + } catch (e: Exception) { + logger.error("更新通知配置启用状态失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 删除配置 + */ + @Transactional + suspend fun deleteConfig(id: Long): Result { + return try { + withContext(Dispatchers.IO) { + notificationConfigRepository.deleteById(id) + } + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除通知配置失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 验证配置数据 + */ + private fun validateConfig(type: String, config: Map) { + when (type.lowercase()) { + "telegram" -> { + val botToken = config["botToken"] as? String + val chatIds = config["chatIds"] + + if (botToken.isNullOrBlank()) { + throw IllegalArgumentException("Telegram Bot Token 不能为空") + } + + if (chatIds == null) { + throw IllegalArgumentException("Telegram Chat IDs 不能为空") + } + + // 支持数组或逗号分隔的字符串 + val chatIdList = when (chatIds) { + is List<*> -> chatIds.mapNotNull { it?.toString() }.filter { it.isNotBlank() } + is String -> chatIds.split(",").map { it.trim() }.filter { it.isNotBlank() } + else -> throw IllegalArgumentException("Chat IDs 格式错误,应为数组或逗号分隔的字符串") + } + + if (chatIdList.isEmpty()) { + throw IllegalArgumentException("至少需要一个 Chat ID") + } + } + // 未来可以添加其他类型的验证 + else -> { + // 其他类型暂时不验证,允许扩展 + } + } + } + + /** + * 实体转 DTO + */ + private fun entityToDto(entity: NotificationConfig): NotificationConfigDto { + val configMap = try { + objectMapper.readValue(entity.configJson, Map::class.java) as Map + } catch (e: Exception) { + logger.error("解析配置 JSON 失败: ${e.message}", e) + emptyMap() + } + + val configData = when (entity.type.lowercase()) { + "telegram" -> { + val botToken = configMap["botToken"]?.toString() ?: "" + val chatIds = when (val ids = configMap["chatIds"]) { + is List<*> -> ids.mapNotNull { it?.toString() } + is String -> ids.split(",").map { it.trim() } + else -> emptyList() + } + NotificationConfigData.Telegram(TelegramConfigData(botToken, chatIds)) + } + else -> { + // 其他类型暂时不支持,返回空配置 + NotificationConfigData.Telegram(TelegramConfigData("", emptyList())) + } + } + + return NotificationConfigDto( + id = entity.id, + type = entity.type, + name = entity.name, + enabled = entity.enabled, + config = configData, + createdAt = entity.createdAt, + updatedAt = entity.updatedAt + ) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt new file mode 100644 index 0000000..39b5ee0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt @@ -0,0 +1,636 @@ +package com.wrbug.polymarketbot.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.wrbug.polymarketbot.dto.NotificationConfigData +import com.wrbug.polymarketbot.dto.TelegramConfigData +import com.wrbug.polymarketbot.util.createClient +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +/** + * Telegram 通知服务 + * 负责发送 Telegram 消息 + */ +@Service +class TelegramNotificationService( + private val notificationConfigService: NotificationConfigService, + private val objectMapper: ObjectMapper, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(TelegramNotificationService::class.java) + + private val okHttpClient = createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + private val apiBaseUrl = "https://api.telegram.org/bot" + + // 协程作用域 + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** + * 发送订单成功通知 + * @param orderId 订单ID(用于查询订单详情获取实际价格和数量) + * @param marketTitle 市场标题 + * @param marketId 市场ID(conditionId),用于生成链接 + * @param marketSlug 市场slug,用于生成链接 + * @param side 订单方向(BUY/SELL),用于多语言显示 + * @param accountName 账户名称 + * @param walletAddress 钱包地址 + * @param clobApi CLOB API 客户端(可选,如果提供则查询订单详情获取实际价格和数量) + * @param apiKey API Key(可选,用于查询订单详情) + * @param apiSecret API Secret(可选,用于查询订单详情) + * @param apiPassphrase API Passphrase(可选,用于查询订单详情) + * @param walletAddressForApi 钱包地址(可选,用于查询订单详情) + * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) + */ + suspend fun sendOrderSuccessNotification( + orderId: String?, + marketTitle: String, + marketId: String? = null, + marketSlug: String? = null, + side: String, + accountName: String? = null, + walletAddress: String? = null, + clobApi: com.wrbug.polymarketbot.api.PolymarketClobApi? = null, + apiKey: String? = null, + apiSecret: String? = null, + apiPassphrase: String? = null, + walletAddressForApi: String? = null, + locale: java.util.Locale? = null + ) { + // 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取) + val currentLocale = locale ?: try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e) + java.util.Locale("zh", "CN") // 默认简体中文 + } + + // 尝试从订单详情获取实际价格和数量 + var actualPrice: String? = null + var actualSize: String? = null + var actualSide: String = side + var actualOutcome: String? = null // 市场方向(outcome) + + if (orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) { + try { + val orderResponse = clobApi.getOrder(orderId) + if (orderResponse.isSuccessful && orderResponse.body() != null) { + val order = orderResponse.body()!! + actualPrice = order.price + actualSize = order.originalSize // 使用 originalSize 作为订单数量 + actualSide = order.side // 使用订单详情中的 side + actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向) + } + } catch (e: Exception) { + logger.warn("查询订单详情失败,使用默认值: ${e.message}", e) + } + } + + // 如果没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留) + val price = actualPrice ?: "0" + val size = actualSize ?: "0" + + // 计算订单金额 = price × size(USDC) + val amount = try { + val priceDecimal = price.toSafeBigDecimal() + val sizeDecimal = size.toSafeBigDecimal() + priceDecimal.multiply(sizeDecimal).toString() + } catch (e: Exception) { + logger.warn("计算订单金额失败: ${e.message}", e) + null + } + + val message = buildOrderSuccessMessage( + orderId = orderId, + marketTitle = marketTitle, + marketId = marketId, + marketSlug = marketSlug, + side = actualSide, + outcome = actualOutcome, + price = price, + size = size, + amount = amount, + accountName = accountName, + walletAddress = walletAddress, + locale = currentLocale + ) + sendMessage(message) + } + + /** + * 发送订单失败通知 + * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) + */ + suspend fun sendOrderFailureNotification( + marketTitle: String, + marketId: String? = null, // 市场ID(conditionId),用于生成链接 + marketSlug: String? = null, // 市场slug,用于生成链接 + side: String, + outcome: String? = null, // 市场方向(outcome,如 "YES", "NO" 等) + price: String, + size: String, + errorMessage: String, // 只传递后端返回的 msg,不传递完整堆栈 + accountName: String? = null, + walletAddress: String? = null, + locale: java.util.Locale? = null + ) { + // 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取) + val currentLocale = locale ?: try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e) + java.util.Locale("zh", "CN") // 默认简体中文 + } + + // 计算订单金额 = price × size(USDC) + val amount = try { + val priceDecimal = price.toSafeBigDecimal() + val sizeDecimal = size.toSafeBigDecimal() + priceDecimal.multiply(sizeDecimal).toString() + } catch (e: Exception) { + logger.warn("计算订单金额失败: ${e.message}", e) + null + } + + val message = buildOrderFailureMessage( + marketTitle = marketTitle, + marketId = marketId, + marketSlug = marketSlug, + side = side, + outcome = outcome, + price = price, + size = size, + amount = amount, + errorMessage = errorMessage, + accountName = accountName, + walletAddress = walletAddress, + locale = currentLocale + ) + sendMessage(message) + } + + /** + * 发送测试消息 + */ + suspend fun sendTestMessage(message: String = "这是一条测试消息"): Boolean { + return try { + val configs = notificationConfigService.getEnabledConfigsByType("telegram") + if (configs.isEmpty()) { + logger.warn("没有启用的 Telegram 配置") + return false + } + + return coroutineScope { + val results = configs.map { config -> + async(Dispatchers.IO) { + when (val configData = config.config) { + is NotificationConfigData.Telegram -> { + sendTelegramMessage(configData.data, message) + } + + else -> false + } + } + }.awaitAll() + + results.any { it } + } + } catch (e: Exception) { + logger.error("发送测试消息失败: ${e.message}", e) + false + } + } + + /** + * 发送消息(发送给所有启用的 Telegram 配置) + */ + private suspend fun sendMessage(message: String) { + try { + val configs = notificationConfigService.getEnabledConfigsByType("telegram") + if (configs.isEmpty()) { + logger.debug("没有启用的 Telegram 配置,跳过发送消息") + return + } + + // 异步发送给所有配置 + configs.forEach { config -> + scope.launch { + try { + when (val configData = config.config) { + is NotificationConfigData.Telegram -> { + sendTelegramMessage(configData.data, message) + } + + else -> { + logger.warn("不支持的配置类型: ${config.type}") + } + } + } catch (e: Exception) { + logger.error("发送 Telegram 消息失败 (configId=${config.id}): ${e.message}", e) + } + } + } + } catch (e: Exception) { + logger.error("发送通知消息失败: ${e.message}", e) + } + } + + /** + * 发送 Telegram 消息 + */ + private suspend fun sendTelegramMessage(config: TelegramConfigData, message: String): Boolean { + return withContext(Dispatchers.IO) { + try { + val results = config.chatIds.map { chatId -> + async { + sendToSingleChat(config.botToken, chatId, message) + } + }.awaitAll() + + results.any { it } + } catch (e: Exception) { + logger.error("发送 Telegram 消息失败: ${e.message}", e) + false + } + } + } + + /** + * 获取 Chat IDs(通过 getUpdates API) + * 需要用户先向机器人发送消息 + */ + suspend fun getChatIds(botToken: String): Result> { + return withContext(Dispatchers.IO) { + try { + val url = "$apiBaseUrl$botToken/getUpdates" + + val request = Request.Builder() + .url(url) + .get() + .build() + + val response = okHttpClient.newCall(request).execute() + + if (!response.isSuccessful) { + val errorBody = response.body?.string() + response.close() + return@withContext Result.failure( + Exception("获取 Chat IDs 失败: code=${response.code}, body=$errorBody") + ) + } + + val responseBody = response.body?.string() ?: "" + response.close() + + // 解析 JSON 响应 + val jsonNode = objectMapper.readTree(responseBody) + + if (jsonNode.get("ok")?.asBoolean()?.not() ?: false) { + val description = jsonNode.get("description")?.asText() ?: "未知错误" + return@withContext Result.failure(Exception("Telegram API 错误: $description")) + } + + val result = jsonNode.get("result") + if (result == null || !result.isArray) { + return@withContext Result.failure(Exception("未找到消息记录,请先向机器人发送一条消息(如 /start)")) + } + + // 提取所有唯一的 chat.id + val chatIds = mutableSetOf() + result.forEach { update -> + val message = update.get("message") + if (message != null) { + val chat = message.get("chat") + if (chat != null) { + val chatId = chat.get("id")?.asText() + if (chatId != null) { + chatIds.add(chatId) + } + } + } + } + + if (chatIds.isEmpty()) { + return@withContext Result.failure( + Exception("未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试") + ) + } + + Result.success(chatIds.toList()) + } catch (e: Exception) { + logger.error("获取 Chat IDs 异常: ${e.message}", e) + Result.failure(e) + } + } + } + + /** + * 发送到单个 Chat + */ + private suspend fun sendToSingleChat(botToken: String, chatId: String, message: String): Boolean { + return try { + val url = "$apiBaseUrl$botToken/sendMessage" + + val requestBody = objectMapper.writeValueAsString( + mapOf( + "chat_id" to chatId, + "text" to message, + "parse_mode" to "HTML", // 支持 HTML 格式 + "disable_web_page_preview" to false // 允许显示链接预览 + ) + ) + + val request = Request.Builder() + .url(url) + .post(requestBody.toRequestBody("application/json".toMediaType())) + .build() + + val response = okHttpClient.newCall(request).execute() + val isSuccess = response.isSuccessful + + if (!isSuccess) { + val errorBody = response.body?.string() + logger.error("Telegram API 调用失败: code=${response.code}, body=$errorBody") + } + + response.close() + isSuccess + } catch (e: Exception) { + logger.error("发送 Telegram 消息异常: ${e.message}", e) + false + } + } + + /** + * 构建订单成功消息 + */ + private fun buildOrderSuccessMessage( + 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 + ): String { + + // 获取多语言文本 + val orderCreatedSuccess = messageSource.getMessage("notification.order.created.success", null, "订单创建成功", locale) + val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale) + val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale) + val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale) + val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale) + val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale) + val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale) + val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale) + 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 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) + + // 获取方向的多语言文本 + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale) + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale) + else -> side + } + + // 优先使用账户名称,如果没有账户名称才显示钱包地址 + val accountInfo: String = when { + !accountName.isNullOrBlank() -> { + accountName!! + } + !walletAddress.isNullOrBlank() -> { + maskAddress(walletAddress!!) + } + else -> { + unknownAccount + } + } + + val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date()) + + // 转义 HTML 特殊字符 + val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">") + val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">") + + // 格式化金额显示 + val amountDisplay = if (amount != null) { + try { + // 保留最多4位小数,去除尾随零 + 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 + } + } else { + calculateFailed + } + + // 生成市场链接 + val marketLink = when { + !marketSlug.isNullOrBlank() -> { + "https://polymarket.com/event/$marketSlug" + } + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> { + "https://polymarket.com/condition/$marketId" + } + else -> null + } + + val marketDisplay = if (marketLink != null) { + "$escapedMarketTitle" + } else { + escapedMarketTitle + } + + // 显示市场方向(outcome) + val outcomeDisplay = if (!outcome.isNullOrBlank()) { + val escapedOutcome = outcome.replace("<", "<").replace(">", ">") + "\n• $outcomeLabel: $escapedOutcome" + } else { + "" + } + + return """ + ✅ $orderCreatedSuccess + + 📊 $orderInfo: + • $orderIdLabel: ${orderId ?: unknown} + • $marketLabel: $marketDisplay$outcomeDisplay + • $sideLabel: $sideDisplay + • $priceLabel: $price + • $quantityLabel: $size shares + • $amountLabel: $amountDisplay USDC + • $accountLabel: $escapedAccountInfo + + ⏰ $timeLabel: $time + """.trimIndent() + } + + /** + * 构建订单失败消息 + */ + private fun buildOrderFailureMessage( + 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 + ): String { + + // 获取多语言文本 + val orderCreatedFailed = messageSource.getMessage("notification.order.created.failed", null, "订单创建失败", locale) + val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale) + val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale) + val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale) + val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale) + val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale) + val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale) + val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale) + val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale) + val errorInfo = messageSource.getMessage("notification.order.error_info", null, "错误信息", locale) + val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale) + + // 获取方向的多语言文本 + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale) + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale) + else -> side + } + + // 优先使用账户名称,如果没有账户名称才显示钱包地址 + val accountInfo: String = when { + !accountName.isNullOrBlank() -> { + accountName!! + } + !walletAddress.isNullOrBlank() -> { + maskAddress(walletAddress!!) + } + else -> { + unknownAccount + } + } + + val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date()) + + // 错误信息已经是后端返回的 msg,不需要截断(但为了安全,限制长度) + val shortErrorMessage = if (errorMessage.length > 500) { + errorMessage.substring(0, 500) + "..." + } else { + errorMessage + } + + // 转义 HTML 特殊字符 + val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">") + val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">") + val escapedErrorMessage = shortErrorMessage.replace("<", "<").replace(">", ">") + + // 格式化金额显示 + val amountDisplay = if (amount != null) { + try { + // 保留最多4位小数,去除尾随零 + 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 + } + } else { + calculateFailed + } + + // 生成市场链接 + val marketLink = when { + !marketSlug.isNullOrBlank() -> { + "https://polymarket.com/event/$marketSlug" + } + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> { + "https://polymarket.com/condition/$marketId" + } + else -> null + } + + val marketDisplay = if (marketLink != null) { + "$escapedMarketTitle" + } else { + escapedMarketTitle + } + + // 显示市场方向(outcome) + val outcomeDisplay = if (!outcome.isNullOrBlank()) { + val escapedOutcome = outcome.replace("<", "<").replace(">", ">") + "\n• $outcomeLabel: $escapedOutcome" + } else { + "" + } + + return """ + ❌ $orderCreatedFailed + + 📊 $orderInfo: + • $marketLabel: $marketDisplay$outcomeDisplay + • $sideLabel: $sideDisplay + • $priceLabel: $price + • $quantityLabel: $size shares + • $amountLabel: $amountDisplay USDC + • $accountLabel: $escapedAccountInfo + + ⚠️ $errorInfo: + $escapedErrorMessage + + ⏰ $timeLabel: $time + """.trimIndent() + } + + /** + * 脱敏显示地址(只显示前6位和后4位) + */ + private fun maskAddress(address: String): String { + if (address.length <= 10) { + return address + } + return "${address.substring(0, 6)}...${address.substring(address.length - 4)}" + } +} + diff --git a/backend/src/main/resources/db/migration/V3__create_notification_configs.sql b/backend/src/main/resources/db/migration/V3__create_notification_configs.sql new file mode 100644 index 0000000..9522559 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__create_notification_configs.sql @@ -0,0 +1,39 @@ +-- ============================================ +-- 创建消息推送配置表 +-- 支持多种推送方式(Telegram、Discord、Slack 等) +-- ============================================ + +CREATE TABLE IF NOT EXISTS notification_configs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(50) NOT NULL COMMENT '推送类型(telegram、discord、slack 等)', + name VARCHAR(100) NOT NULL COMMENT '配置名称(用于显示)', + enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + config_json TEXT NOT NULL COMMENT '配置信息(JSON格式,不同类型存储不同字段)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_type (type), + INDEX idx_enabled (enabled), + INDEX idx_type_enabled (type, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送配置表'; + +-- ============================================ +-- 配置说明 +-- ============================================ +-- config_json 字段存储 JSON 格式的配置信息 +-- +-- Telegram 配置示例: +-- { +-- "botToken": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", +-- "chatIds": ["123456789", "987654321"] +-- } +-- +-- Discord 配置示例(未来扩展): +-- { +-- "webhookUrl": "https://discord.com/api/webhooks/..." +-- } +-- +-- Slack 配置示例(未来扩展): +-- { +-- "webhookUrl": "https://hooks.slack.com/services/..." +-- } + diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index e69de29..79a756d 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -0,0 +1,231 @@ +# Notification related +notification.order.created.success=Order Created Successfully +notification.order.created.failed=Order Creation Failed +notification.order.info=Order Information +notification.order.id=Order ID +notification.order.market=Market +notification.order.side=Side +notification.order.side.buy=Buy +notification.order.side.sell=Sell +notification.order.outcome=Market Outcome +notification.order.price=Price +notification.order.quantity=Quantity +notification.order.amount=Amount +notification.order.account=Account +notification.order.time=Time +notification.order.error_info=Error Information +notification.order.unknown_account=Unknown Account +notification.order.calculate_failed=Calculation Failed + +# Common +common.unknown=Unknown + +# ==================== Parameter Errors (1001-1999) ==================== +error.param.error=Parameter Error +error.param.empty=Parameter cannot be empty +error.param.invalid=Parameter is invalid + +# Account related parameter errors +error.param.private_key_empty=Private key cannot be empty +error.param.wallet_address_empty=Wallet address cannot be empty +error.param.wallet_address_invalid=Wallet address format is invalid +error.param.account_id_invalid=Account ID is invalid +error.param.account_name_empty=Account name cannot be empty + +# Leader related parameter errors +error.param.leader_address_empty=Leader address cannot be empty +error.param.leader_address_invalid=Leader address format is invalid +error.param.leader_id_invalid=Leader ID is invalid +error.param.leader_name_empty=Leader name cannot be empty +error.param.category_invalid=Category is invalid, only supports sports or crypto + +# Template related parameter errors +error.param.template_name_empty=Template name cannot be empty +error.param.template_id_invalid=Template ID is invalid +error.param.copy_mode_invalid=copyMode must be RATIO or FIXED +error.param.copy_ratio_invalid=Copy ratio is invalid +error.param.fixed_amount_invalid=Fixed amount is invalid + +# Copy trading related parameter errors +error.param.copy_trading_id_invalid=Copy trading ID is invalid +error.param.order_type_invalid=Order type is invalid +error.param.order_type_must_be_market_or_limit=Order type must be MARKET or LIMIT +error.param.quantity_empty=Quantity cannot be empty +error.param.price_empty=Limit order must provide price +error.param.side_empty=Side cannot be empty +error.param.market_id_empty=Market ID cannot be empty +error.param.order_type_empty=Order type cannot be empty + +# Market related parameter errors +error.param.token_id_empty=tokenId cannot be empty +error.param.condition_id_empty=conditionId cannot be empty +error.param.redeem_positions_empty=Redeem positions list cannot be empty +error.param.index_sets_invalid=Index sets is invalid + +# Statistics related parameter errors +error.param.order_type_invalid_for_tracking=Order type is invalid, must be: buy, sell, matched + +# ==================== Authentication/Permission Errors (2001-2999) ==================== +error.auth.error=Authentication Failed +error.auth.token_invalid=Authentication token is invalid +error.auth.token_expired=Authentication token has expired +error.auth.permission_denied=Permission denied +error.auth.api_key_invalid=API Key is invalid +error.auth.api_secret_invalid=API Secret is invalid +error.auth.api_passphrase_invalid=API Passphrase is invalid +error.auth.api_credentials_missing=API credentials not configured +error.auth.username_or_password_error=Username or password is incorrect +error.auth.reset_key_invalid=Reset key is invalid +error.auth.reset_password_rate_limit=Rate limit: Maximum 3 attempts per minute, please try again later +error.auth.user_not_found=User not found +error.auth.password_weak=Password length does not meet requirements, at least 6 characters + +# ==================== Resource Not Found (3001-3999) ==================== +error.not_found=Resource not found +error.account_not_found=Account not found +error.leader_not_found=Leader not found +error.template_not_found=Template not found +error.copy_trading_not_found=Copy trading not found +error.market_not_found=Market not found +error.order_not_found=Order not found +error.position_not_found=Position not found + +# ==================== Business Logic Errors (4001-4999) ==================== +error.business.error=Business logic error + +# Leader management +error.leader_already_exists=This Leader address already exists +error.leader_address_same_as_account=Leader address cannot be the same as your account address +error.leader_has_copy_tradings=This Leader still has copy trading relationships, please delete them first + +# Template management +error.template_name_already_exists=Template name already exists +error.template_has_copy_tradings=This template is still being used by copy trading relationships, please delete them first + +# Copy trading management +error.copy_trading_already_exists=This copy trading relationship already exists +error.copy_trading_disabled=Copy trading relationship is disabled +error.copy_trading_enabled=Copy trading relationship is enabled +error.no_enabled_copy_tradings=No enabled copy trading relationships + +# Order related +error.order_create_failed=Failed to create order +error.order_cancel_failed=Failed to cancel order +error.order_not_matched=Order not matched +error.order_already_filled=Order already filled +error.order_insufficient_balance=Insufficient balance +error.order_amount_too_small=Order amount is below minimum limit +error.order_amount_too_large=Order amount exceeds maximum limit +error.order_price_invalid=Order price is invalid +error.order_quantity_invalid=Order quantity is invalid + +# Market related +error.market_price_fetch_failed=Failed to fetch market price +error.market_orderbook_empty=Orderbook is empty +error.market_token_id_invalid=Token ID is invalid + +# Position related +error.position_redeem_failed=Failed to redeem position +error.position_not_redeemable=Position is not redeemable +error.position_insufficient=Insufficient position +error.position_already_redeemed=Position already redeemed + +# Notification config related +error.notification_config_not_found=Notification config not found +error.notification_config_id_empty=Config ID cannot be empty +error.notification_config_type_empty=Notification type cannot be empty +error.notification_config_name_empty=Config name cannot be empty +error.notification_config_data_empty=Config data cannot be empty +error.notification_config_bot_token_empty=Bot Token cannot be empty +error.notification_config_create_failed=Failed to create config +error.notification_config_update_failed=Failed to update config +error.notification_config_delete_failed=Failed to delete config +error.notification_config_update_enabled_failed=Failed to update enabled status +error.notification_config_fetch_failed=Failed to fetch config +error.notification_test_failed=Failed to send test message, please check config +error.notification_get_chat_ids_failed=Failed to get Chat IDs + +# Account related business errors +error.account_already_exists=Account already exists +error.account_is_default=Account is already the default account +error.account_has_active_orders=Account has active orders +error.account_is_last_one=Cannot delete the last account +error.account_api_key_create_failed=Failed to automatically get API Key +error.account_proxy_address_fetch_failed=Failed to fetch proxy address +error.account_balance_fetch_failed=Failed to query account balance +error.account_positions_fetch_failed=Failed to query positions list + +# Statistics related +error.statistics_fetch_failed=Failed to fetch statistics +error.order_list_fetch_failed=Failed to query order list + +# ==================== Server Internal Errors (5001-5999) ==================== +error.server.error=Server internal error +error.server.database_error=Database error +error.server.network_error=Network error +error.server.timeout=Request timeout +error.server.external_api_error=External API call failed +error.server.rpc_error=RPC call failed +error.server.websocket_error=WebSocket connection error +error.server.encryption_error=Encryption/decryption error +error.server.signature_error=Signature error + +# Account service errors +error.server.account_import_failed=Failed to import account +error.server.account_update_failed=Failed to update account +error.server.account_delete_failed=Failed to delete account +error.server.account_list_fetch_failed=Failed to query account list +error.server.account_detail_fetch_failed=Failed to query account detail +error.server.account_balance_fetch_failed=Failed to query account balance +error.server.account_default_set_failed=Failed to set default account +error.server.account_positions_fetch_failed=Failed to query positions list +error.server.account_order_create_failed=Failed to create sell order +error.server.account_redeem_positions_failed=Failed to redeem positions + +# Leader service errors +error.server.leader_add_failed=Failed to add Leader +error.server.leader_update_failed=Failed to update Leader +error.server.leader_delete_failed=Failed to delete Leader +error.server.leader_list_fetch_failed=Failed to query Leader list +error.server.leader_detail_fetch_failed=Failed to query Leader detail + +# Template service errors +error.server.template_create_failed=Failed to create template +error.server.template_update_failed=Failed to update template +error.server.template_delete_failed=Failed to delete template +error.server.template_copy_failed=Failed to copy template +error.server.template_list_fetch_failed=Failed to query template list +error.server.template_detail_fetch_failed=Failed to query template detail + +# Copy trading service errors +error.server.copy_trading_create_failed=Failed to create copy trading +error.server.copy_trading_update_failed=Failed to update copy trading +error.server.copy_trading_delete_failed=Failed to delete copy trading +error.server.copy_trading_list_fetch_failed=Failed to query copy trading list +error.server.copy_trading_templates_fetch_failed=Failed to query templates bound to wallet + +# Market service errors +error.server.market_price_fetch_failed=Failed to fetch market price +error.server.market_latest_price_fetch_failed=Failed to fetch latest price + +# Statistics service errors +error.server.statistics_fetch_failed=Failed to fetch statistics +error.server.order_tracking_list_fetch_failed=Failed to query order list + +# Blockchain service errors +error.server.blockchain_rpc_error=Blockchain RPC call failed +error.server.blockchain_proxy_address_fetch_failed=Failed to fetch proxy address +error.server.blockchain_balance_fetch_failed=Failed to query balance +error.server.blockchain_positions_fetch_failed=Failed to query positions +error.server.blockchain_redeem_failed=Failed to redeem position transaction + +# WebSocket service errors +error.server.websocket_connection_failed=WebSocket connection failed +error.server.websocket_message_send_failed=WebSocket message send failed +error.server.websocket_subscribe_failed=WebSocket subscribe failed + +# Order tracking service errors +error.server.order_tracking_process_failed=Failed to process order tracking +error.server.order_tracking_buy_failed=Failed to process buy order +error.server.order_tracking_sell_failed=Failed to process sell order +error.server.order_tracking_match_failed=Order matching failed diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index e69de29..c4143f7 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,231 @@ +# 通知相关 +notification.order.created.success=订单创建成功 +notification.order.created.failed=订单创建失败 +notification.order.info=订单信息 +notification.order.id=订单ID +notification.order.market=市场 +notification.order.side=方向 +notification.order.side.buy=买入 +notification.order.side.sell=卖出 +notification.order.outcome=市场方向 +notification.order.price=价格 +notification.order.quantity=数量 +notification.order.amount=金额 +notification.order.account=账户 +notification.order.time=时间 +notification.order.error_info=错误信息 +notification.order.unknown_account=未知账户 +notification.order.calculate_failed=计算失败 + +# 通用 +common.unknown=未知 + +# ==================== 参数错误 (1001-1999) ==================== +error.param.error=参数错误 +error.param.empty=参数不能为空 +error.param.invalid=参数无效 + +# 账户相关参数错误 +error.param.private_key_empty=私钥不能为空 +error.param.wallet_address_empty=钱包地址不能为空 +error.param.wallet_address_invalid=钱包地址格式无效 +error.param.account_id_invalid=账户ID无效 +error.param.account_name_empty=账户名称不能为空 + +# Leader 相关参数错误 +error.param.leader_address_empty=Leader 地址不能为空 +error.param.leader_address_invalid=Leader 地址格式无效 +error.param.leader_id_invalid=Leader ID 无效 +error.param.leader_name_empty=Leader 名称不能为空 +error.param.category_invalid=分类无效,只支持 sports 或 crypto + +# 模板相关参数错误 +error.param.template_name_empty=模板名称不能为空 +error.param.template_id_invalid=模板 ID 无效 +error.param.copy_mode_invalid=copyMode 必须是 RATIO 或 FIXED +error.param.copy_ratio_invalid=跟单比例无效 +error.param.fixed_amount_invalid=固定金额无效 + +# 跟单相关参数错误 +error.param.copy_trading_id_invalid=跟单关系ID无效 +error.param.order_type_invalid=订单类型无效 +error.param.order_type_must_be_market_or_limit=订单类型必须是MARKET或LIMIT +error.param.quantity_empty=数量不能为空 +error.param.price_empty=限价订单必须提供价格 +error.param.side_empty=方向不能为空 +error.param.market_id_empty=市场ID不能为空 +error.param.order_type_empty=订单类型不能为空 + +# 市场相关参数错误 +error.param.token_id_empty=tokenId 不能为空 +error.param.condition_id_empty=conditionId 不能为空 +error.param.redeem_positions_empty=赎回仓位列表不能为空 +error.param.index_sets_invalid=结果索引无效 + +# 统计相关参数错误 +error.param.order_type_invalid_for_tracking=订单类型无效,必须是: buy, sell, matched + +# ==================== 认证/权限错误 (2001-2999) ==================== +error.auth.error=认证失败 +error.auth.token_invalid=认证令牌无效 +error.auth.token_expired=认证令牌已过期 +error.auth.permission_denied=权限不足 +error.auth.api_key_invalid=API Key 无效 +error.auth.api_secret_invalid=API Secret 无效 +error.auth.api_passphrase_invalid=API Passphrase 无效 +error.auth.api_credentials_missing=API 凭证未配置 +error.auth.username_or_password_error=用户名或密码错误 +error.auth.reset_key_invalid=重置密钥错误 +error.auth.reset_password_rate_limit=频率限制:1分钟内最多尝试3次,请稍后再试 +error.auth.user_not_found=用户不存在 +error.auth.password_weak=密码长度不符合要求,至少6位 + +# ==================== 资源不存在 (3001-3999) ==================== +error.not_found=资源不存在 +error.account_not_found=账户不存在 +error.leader_not_found=Leader 不存在 +error.template_not_found=模板不存在 +error.copy_trading_not_found=跟单关系不存在 +error.market_not_found=市场不存在 +error.order_not_found=订单不存在 +error.position_not_found=仓位不存在 + +# ==================== 业务逻辑错误 (4001-4999) ==================== +error.business.error=业务逻辑错误 + +# Leader 管理 +error.leader_already_exists=该 Leader 地址已存在 +error.leader_address_same_as_account=Leader 地址不能与自己的账户地址相同 +error.leader_has_copy_tradings=该 Leader 还有跟单关系,请先删除跟单关系 + +# 模板管理 +error.template_name_already_exists=模板名称已存在 +error.template_has_copy_tradings=该模板还有跟单关系在使用,请先删除跟单关系 + +# 跟单管理 +error.copy_trading_already_exists=该跟单关系已存在 +error.copy_trading_disabled=跟单关系已禁用 +error.copy_trading_enabled=跟单关系已启用 +error.no_enabled_copy_tradings=没有启用的跟单关系 + +# 订单相关 +error.order_create_failed=创建订单失败 +error.order_cancel_failed=取消订单失败 +error.order_not_matched=订单未匹配 +error.order_already_filled=订单已成交 +error.order_insufficient_balance=余额不足 +error.order_amount_too_small=订单金额低于最小限制 +error.order_amount_too_large=订单金额超过最大限制 +error.order_price_invalid=订单价格无效 +error.order_quantity_invalid=订单数量无效 + +# 市场相关 +error.market_price_fetch_failed=获取市场价格失败 +error.market_orderbook_empty=订单簿为空 +error.market_token_id_invalid=Token ID 无效 + +# 仓位相关 +error.position_redeem_failed=赎回仓位失败 +error.position_not_redeemable=仓位不可赎回 +error.position_insufficient=仓位不足 +error.position_already_redeemed=仓位已赎回 + +# 通知配置相关 +error.notification_config_not_found=通知配置不存在 +error.notification_config_id_empty=配置ID不能为空 +error.notification_config_type_empty=推送类型不能为空 +error.notification_config_name_empty=配置名称不能为空 +error.notification_config_data_empty=配置信息不能为空 +error.notification_config_bot_token_empty=Bot Token 不能为空 +error.notification_config_create_failed=创建配置失败 +error.notification_config_update_failed=更新配置失败 +error.notification_config_delete_failed=删除配置失败 +error.notification_config_update_enabled_failed=更新启用状态失败 +error.notification_config_fetch_failed=获取配置失败 +error.notification_test_failed=发送测试消息失败,请检查配置 +error.notification_get_chat_ids_failed=获取 Chat IDs 失败 + +# 账户相关业务错误 +error.account_already_exists=账户已存在 +error.account_is_default=账户已是默认账户 +error.account_has_active_orders=账户有活跃订单 +error.account_is_last_one=不能删除最后一个账户 +error.account_api_key_create_failed=自动获取 API Key 失败 +error.account_proxy_address_fetch_failed=获取代理地址失败 +error.account_balance_fetch_failed=查询账户余额失败 +error.account_positions_fetch_failed=查询仓位列表失败 + +# 统计相关 +error.statistics_fetch_failed=获取统计信息失败 +error.order_list_fetch_failed=查询订单列表失败 + +# ==================== 服务器内部错误 (5001-5999) ==================== +error.server.error=服务器内部错误 +error.server.database_error=数据库错误 +error.server.network_error=网络错误 +error.server.timeout=请求超时 +error.server.external_api_error=外部API调用失败 +error.server.rpc_error=RPC调用失败 +error.server.websocket_error=WebSocket连接错误 +error.server.encryption_error=加密/解密错误 +error.server.signature_error=签名错误 + +# 账户服务错误 +error.server.account_import_failed=导入账户失败 +error.server.account_update_failed=更新账户失败 +error.server.account_delete_failed=删除账户失败 +error.server.account_list_fetch_failed=查询账户列表失败 +error.server.account_detail_fetch_failed=查询账户详情失败 +error.server.account_balance_fetch_failed=查询账户余额失败 +error.server.account_default_set_failed=设置默认账户失败 +error.server.account_positions_fetch_failed=查询仓位列表失败 +error.server.account_order_create_failed=创建卖出订单失败 +error.server.account_redeem_positions_failed=赎回仓位失败 + +# Leader 服务错误 +error.server.leader_add_failed=添加 Leader 失败 +error.server.leader_update_failed=更新 Leader 失败 +error.server.leader_delete_failed=删除 Leader 失败 +error.server.leader_list_fetch_failed=查询 Leader 列表失败 +error.server.leader_detail_fetch_failed=查询 Leader 详情失败 + +# 模板服务错误 +error.server.template_create_failed=创建模板失败 +error.server.template_update_failed=更新模板失败 +error.server.template_delete_failed=删除模板失败 +error.server.template_copy_failed=复制模板失败 +error.server.template_list_fetch_failed=查询模板列表失败 +error.server.template_detail_fetch_failed=查询模板详情失败 + +# 跟单服务错误 +error.server.copy_trading_create_failed=创建跟单失败 +error.server.copy_trading_update_failed=更新跟单失败 +error.server.copy_trading_delete_failed=删除跟单失败 +error.server.copy_trading_list_fetch_failed=查询跟单列表失败 +error.server.copy_trading_templates_fetch_failed=查询钱包绑定的模板失败 + +# 市场服务错误 +error.server.market_price_fetch_failed=获取市场价格失败 +error.server.market_latest_price_fetch_failed=获取最新价失败 + +# 统计服务错误 +error.server.statistics_fetch_failed=获取统计信息失败 +error.server.order_tracking_list_fetch_failed=查询订单列表失败 + +# 区块链服务错误 +error.server.blockchain_rpc_error=区块链RPC调用失败 +error.server.blockchain_proxy_address_fetch_failed=获取代理地址失败 +error.server.blockchain_balance_fetch_failed=查询余额失败 +error.server.blockchain_positions_fetch_failed=查询仓位失败 +error.server.blockchain_redeem_failed=赎回仓位交易失败 + +# WebSocket 服务错误 +error.server.websocket_connection_failed=WebSocket连接失败 +error.server.websocket_message_send_failed=WebSocket消息发送失败 +error.server.websocket_subscribe_failed=WebSocket订阅失败 + +# 订单跟踪服务错误 +error.server.order_tracking_process_failed=处理订单跟踪失败 +error.server.order_tracking_buy_failed=处理买入订单失败 +error.server.order_tracking_sell_failed=处理卖出订单失败 +error.server.order_tracking_match_failed=订单匹配失败 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index e69de29..3b68739 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -0,0 +1,231 @@ +# 通知相關 +notification.order.created.success=訂單創建成功 +notification.order.created.failed=訂單創建失敗 +notification.order.info=訂單信息 +notification.order.id=訂單ID +notification.order.market=市場 +notification.order.side=方向 +notification.order.side.buy=買入 +notification.order.side.sell=賣出 +notification.order.outcome=市場方向 +notification.order.price=價格 +notification.order.quantity=數量 +notification.order.amount=金額 +notification.order.account=賬戶 +notification.order.time=時間 +notification.order.error_info=錯誤信息 +notification.order.unknown_account=未知賬戶 +notification.order.calculate_failed=計算失敗 + +# 通用 +common.unknown=未知 + +# ==================== 參數錯誤 (1001-1999) ==================== +error.param.error=參數錯誤 +error.param.empty=參數不能為空 +error.param.invalid=參數無效 + +# 賬戶相關參數錯誤 +error.param.private_key_empty=私鑰不能為空 +error.param.wallet_address_empty=錢包地址不能為空 +error.param.wallet_address_invalid=錢包地址格式無效 +error.param.account_id_invalid=賬戶ID無效 +error.param.account_name_empty=賬戶名稱不能為空 + +# Leader 相關參數錯誤 +error.param.leader_address_empty=Leader 地址不能為空 +error.param.leader_address_invalid=Leader 地址格式無效 +error.param.leader_id_invalid=Leader ID 無效 +error.param.leader_name_empty=Leader 名稱不能為空 +error.param.category_invalid=分類無效,只支持 sports 或 crypto + +# 模板相關參數錯誤 +error.param.template_name_empty=模板名稱不能為空 +error.param.template_id_invalid=模板 ID 無效 +error.param.copy_mode_invalid=copyMode 必須是 RATIO 或 FIXED +error.param.copy_ratio_invalid=跟單比例無效 +error.param.fixed_amount_invalid=固定金額無效 + +# 跟單相關參數錯誤 +error.param.copy_trading_id_invalid=跟單關係ID無效 +error.param.order_type_invalid=訂單類型無效 +error.param.order_type_must_be_market_or_limit=訂單類型必須是MARKET或LIMIT +error.param.quantity_empty=數量不能為空 +error.param.price_empty=限價訂單必須提供價格 +error.param.side_empty=方向不能為空 +error.param.market_id_empty=市場ID不能為空 +error.param.order_type_empty=訂單類型不能為空 + +# 市場相關參數錯誤 +error.param.token_id_empty=tokenId 不能為空 +error.param.condition_id_empty=conditionId 不能為空 +error.param.redeem_positions_empty=贖回倉位列表不能為空 +error.param.index_sets_invalid=結果索引無效 + +# 統計相關參數錯誤 +error.param.order_type_invalid_for_tracking=訂單類型無效,必須是: buy, sell, matched + +# ==================== 認證/權限錯誤 (2001-2999) ==================== +error.auth.error=認證失敗 +error.auth.token_invalid=認證令牌無效 +error.auth.token_expired=認證令牌已過期 +error.auth.permission_denied=權限不足 +error.auth.api_key_invalid=API Key 無效 +error.auth.api_secret_invalid=API Secret 無效 +error.auth.api_passphrase_invalid=API Passphrase 無效 +error.auth.api_credentials_missing=API 憑證未配置 +error.auth.username_or_password_error=用戶名或密碼錯誤 +error.auth.reset_key_invalid=重置密鑰錯誤 +error.auth.reset_password_rate_limit=頻率限制:1分鐘內最多嘗試3次,請稍後再試 +error.auth.user_not_found=用戶不存在 +error.auth.password_weak=密碼長度不符合要求,至少6位 + +# ==================== 資源不存在 (3001-3999) ==================== +error.not_found=資源不存在 +error.account_not_found=賬戶不存在 +error.leader_not_found=Leader 不存在 +error.template_not_found=模板不存在 +error.copy_trading_not_found=跟單關係不存在 +error.market_not_found=市場不存在 +error.order_not_found=訂單不存在 +error.position_not_found=倉位不存在 + +# ==================== 業務邏輯錯誤 (4001-4999) ==================== +error.business.error=業務邏輯錯誤 + +# Leader 管理 +error.leader_already_exists=該 Leader 地址已存在 +error.leader_address_same_as_account=Leader 地址不能與自己的賬戶地址相同 +error.leader_has_copy_tradings=該 Leader 還有跟單關係,請先刪除跟單關係 + +# 模板管理 +error.template_name_already_exists=模板名稱已存在 +error.template_has_copy_tradings=該模板還有跟單關係在使用,請先刪除跟單關係 + +# 跟單管理 +error.copy_trading_already_exists=該跟單關係已存在 +error.copy_trading_disabled=跟單關係已禁用 +error.copy_trading_enabled=跟單關係已啟用 +error.no_enabled_copy_tradings=沒有啟用的跟單關係 + +# 訂單相關 +error.order_create_failed=創建訂單失敗 +error.order_cancel_failed=取消訂單失敗 +error.order_not_matched=訂單未匹配 +error.order_already_filled=訂單已成交 +error.order_insufficient_balance=餘額不足 +error.order_amount_too_small=訂單金額低於最小限制 +error.order_amount_too_large=訂單金額超過最大限制 +error.order_price_invalid=訂單價格無效 +error.order_quantity_invalid=訂單數量無效 + +# 市場相關 +error.market_price_fetch_failed=獲取市場價格失敗 +error.market_orderbook_empty=訂單簿為空 +error.market_token_id_invalid=Token ID 無效 + +# 倉位相關 +error.position_redeem_failed=贖回倉位失敗 +error.position_not_redeemable=倉位不可贖回 +error.position_insufficient=倉位不足 +error.position_already_redeemed=倉位已贖回 + +# 通知配置相關 +error.notification_config_not_found=通知配置不存在 +error.notification_config_id_empty=配置ID不能為空 +error.notification_config_type_empty=推送類型不能為空 +error.notification_config_name_empty=配置名稱不能為空 +error.notification_config_data_empty=配置信息不能為空 +error.notification_config_bot_token_empty=Bot Token 不能為空 +error.notification_config_create_failed=創建配置失敗 +error.notification_config_update_failed=更新配置失敗 +error.notification_config_delete_failed=刪除配置失敗 +error.notification_config_update_enabled_failed=更新啟用狀態失敗 +error.notification_config_fetch_failed=獲取配置失敗 +error.notification_test_failed=發送測試消息失敗,請檢查配置 +error.notification_get_chat_ids_failed=獲取 Chat IDs 失敗 + +# 賬戶相關業務錯誤 +error.account_already_exists=賬戶已存在 +error.account_is_default=賬戶已是默認賬戶 +error.account_has_active_orders=賬戶有活躍訂單 +error.account_is_last_one=不能刪除最後一個賬戶 +error.account_api_key_create_failed=自動獲取 API Key 失敗 +error.account_proxy_address_fetch_failed=獲取代理地址失敗 +error.account_balance_fetch_failed=查詢賬戶餘額失敗 +error.account_positions_fetch_failed=查詢倉位列表失敗 + +# 統計相關 +error.statistics_fetch_failed=獲取統計信息失敗 +error.order_list_fetch_failed=查詢訂單列表失敗 + +# ==================== 服務器內部錯誤 (5001-5999) ==================== +error.server.error=服務器內部錯誤 +error.server.database_error=數據庫錯誤 +error.server.network_error=網絡錯誤 +error.server.timeout=請求超時 +error.server.external_api_error=外部API調用失敗 +error.server.rpc_error=RPC調用失敗 +error.server.websocket_error=WebSocket連接錯誤 +error.server.encryption_error=加密/解密錯誤 +error.server.signature_error=簽名錯誤 + +# 賬戶服務錯誤 +error.server.account_import_failed=導入賬戶失敗 +error.server.account_update_failed=更新賬戶失敗 +error.server.account_delete_failed=刪除賬戶失敗 +error.server.account_list_fetch_failed=查詢賬戶列表失敗 +error.server.account_detail_fetch_failed=查詢賬戶詳情失敗 +error.server.account_balance_fetch_failed=查詢賬戶餘額失敗 +error.server.account_default_set_failed=設置默認賬戶失敗 +error.server.account_positions_fetch_failed=查詢倉位列表失敗 +error.server.account_order_create_failed=創建賣出訂單失敗 +error.server.account_redeem_positions_failed=贖回倉位失敗 + +# Leader 服務錯誤 +error.server.leader_add_failed=添加 Leader 失敗 +error.server.leader_update_failed=更新 Leader 失敗 +error.server.leader_delete_failed=刪除 Leader 失敗 +error.server.leader_list_fetch_failed=查詢 Leader 列表失敗 +error.server.leader_detail_fetch_failed=查詢 Leader 詳情失敗 + +# 模板服務錯誤 +error.server.template_create_failed=創建模板失敗 +error.server.template_update_failed=更新模板失敗 +error.server.template_delete_failed=刪除模板失敗 +error.server.template_copy_failed=複製模板失敗 +error.server.template_list_fetch_failed=查詢模板列表失敗 +error.server.template_detail_fetch_failed=查詢模板詳情失敗 + +# 跟單服務錯誤 +error.server.copy_trading_create_failed=創建跟單失敗 +error.server.copy_trading_update_failed=更新跟單失敗 +error.server.copy_trading_delete_failed=刪除跟單失敗 +error.server.copy_trading_list_fetch_failed=查詢跟單列表失敗 +error.server.copy_trading_templates_fetch_failed=查詢錢包綁定的模板失敗 + +# 市場服務錯誤 +error.server.market_price_fetch_failed=獲取市場價格失敗 +error.server.market_latest_price_fetch_failed=獲取最新價失敗 + +# 統計服務錯誤 +error.server.statistics_fetch_failed=獲取統計信息失敗 +error.server.order_tracking_list_fetch_failed=查詢訂單列表失敗 + +# 區塊鏈服務錯誤 +error.server.blockchain_rpc_error=區塊鏈RPC調用失敗 +error.server.blockchain_proxy_address_fetch_failed=獲取代理地址失敗 +error.server.blockchain_balance_fetch_failed=查詢餘額失敗 +error.server.blockchain_positions_fetch_failed=查詢倉位失敗 +error.server.blockchain_redeem_failed=贖回倉位交易失敗 + +# WebSocket 服務錯誤 +error.server.websocket_connection_failed=WebSocket連接失敗 +error.server.websocket_message_send_failed=WebSocket消息發送失敗 +error.server.websocket_subscribe_failed=WebSocket訂閱失敗 + +# 訂單跟蹤服務錯誤 +error.server.order_tracking_process_failed=處理訂單跟蹤失敗 +error.server.order_tracking_buy_failed=處理買入訂單失敗 +error.server.order_tracking_sell_failed=處理賣出訂單失敗 +error.server.order_tracking_match_failed=訂單匹配失敗 diff --git a/docs/telegram-bot-setup.md b/docs/telegram-bot-setup.md new file mode 100644 index 0000000..b7ecb6b --- /dev/null +++ b/docs/telegram-bot-setup.md @@ -0,0 +1,98 @@ +# Telegram 机器人申请和配置指南 + +## 1. 申请 Telegram 机器人 + +### 步骤 1: 创建机器人 +1. 打开 Telegram,搜索 `@BotFather` +2. 点击开始对话,发送 `/newbot` 命令 +3. 按照提示设置机器人名称(例如:`Polymarket Bot`) +4. 设置机器人用户名(必须以 `bot` 结尾,例如:`polymarket_notification_bot`) +5. BotFather 会返回一个 **Bot Token**,格式类似:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz` + +### 步骤 2: 获取 Chat ID +有两种方式获取 Chat ID: + +#### 方式 1: 通过 @userinfobot +1. 在 Telegram 中搜索 `@userinfobot` +2. 点击开始对话,它会自动返回你的 Chat ID(例如:`123456789`) + +#### 方式 2: 通过 API 获取 +1. 使用你的 Bot Token,访问以下 URL: + ``` + https://api.telegram.org/bot/getUpdates + ``` +2. 向你的机器人发送一条消息(例如:`/start`) +3. 再次访问上面的 URL,在返回的 JSON 中找到 `chat.id` 字段 + +### 步骤 3: 配置环境变量 +将 Bot Token 和 Chat ID 配置到系统环境变量或配置文件中: + +```properties +# Telegram Bot 配置 +telegram.bot.token=YOUR_BOT_TOKEN +telegram.bot.chat-id=YOUR_CHAT_ID +``` + +## 2. 测试机器人 +使用 curl 测试机器人是否正常工作: + +```bash +curl -X POST "https://api.telegram.org/bot/sendMessage" \ + -H "Content-Type: application/json" \ + -d '{ + "chat_id": "", + "text": "测试消息" + }' +``` + +如果返回 `{"ok":true,"result":{...}}`,说明配置成功。 + +## 3. 分享机器人给其他用户 + +### 3.1 分享方式 +机器人创建后是公开的,可以通过以下方式分享: + +1. **分享用户名**:`@your_bot_name` +2. **分享链接**:`https://t.me/your_bot_name` +3. **分享二维码**:在 Telegram 中生成机器人二维码 + +### 3.2 用户添加步骤 +其他用户需要: +1. 点击分享的链接或搜索机器人用户名 +2. 点击"开始"按钮或发送 `/start` 命令 +3. 向机器人发送任意消息(如:`hello`) +4. 获取用户的 Chat ID(见下方说明) + +### 3.3 获取其他用户的 Chat ID +**方式 1: 通过 @userinfobot(推荐)** +- 让用户搜索 `@userinfobot` 并开始对话 +- 机器人会自动返回用户的 Chat ID + +**方式 2: 通过你的机器人获取** +- 用户向你的机器人发送消息后 +- 访问:`https://api.telegram.org/bot/getUpdates` +- 在返回的 JSON 中找到 `message.chat.id` 字段 + +**方式 3: 在代码中实现获取(高级)** +- 实现 Webhook 接收用户消息 +- 从消息中提取 `chat.id` + +### 3.4 多用户配置 +支持多个用户接收通知,配置方式: + +```properties +# 单个用户(旧方式,兼容) +telegram.bot.chat-id=123456789 + +# 多个用户(新方式,推荐) +telegram.bot.chat-ids=123456789,987654321,111222333 +``` + +多个 Chat ID 用逗号分隔。 + +## 4. 安全建议 +- **不要将 Bot Token 提交到 Git**:使用环境变量或配置文件(不提交到版本控制) +- **限制机器人权限**:只允许特定用户使用 +- **定期更换 Token**:如果 Token 泄露,可以通过 BotFather 重新生成 +- **保护 Chat ID**:Chat ID 是私密信息,不要公开分享 + diff --git a/docs/telegram-notification-implementation.md b/docs/telegram-notification-implementation.md new file mode 100644 index 0000000..f884df0 --- /dev/null +++ b/docs/telegram-notification-implementation.md @@ -0,0 +1,166 @@ +# Telegram 订单通知实现方案 + +## 1. 架构设计 + +### 1.1 整体架构 +``` +订单处理流程 + ↓ +订单成功/失败事件 + ↓ +TelegramNotificationService (异步发送) + ↓ +Telegram Bot API + ↓ +用户 Telegram 客户端 +``` + +### 1.2 核心组件 +1. **TelegramNotificationService**: 负责发送 Telegram 消息 +2. **TelegramConfig**: 配置类,读取 Bot Token 和 Chat ID +3. **订单处理服务集成**: 在订单成功/失败时调用通知服务 + +## 2. 实现方案 + +### 2.1 方案选择 +- **方案 A(推荐)**: 使用 OkHttp 直接调用 Telegram Bot API + - 优点:轻量级,无需额外依赖,项目已有 OkHttp + - 缺点:需要手动处理 API 调用 + +- **方案 B**: 使用 Telegram Bot Java 库(如 `telegrambots`) + - 优点:功能完整,支持更多特性 + - 缺点:增加依赖,可能功能过于复杂 + +**推荐使用方案 A**,因为: +1. 项目已有 OkHttp 依赖 +2. 只需要发送消息功能,不需要接收消息 +3. 减少依赖,保持项目轻量 + +### 2.2 消息格式设计 + +#### 订单成功消息 +``` +✅ 订单创建成功 + +📊 订单信息: +• 订单ID: order_123456 +• 市场: Market Title +• 方向: BUY +• 价格: 0.50 +• 数量: 100 USDC +• 账户: Account 1 (0x1234...5678) + +⏰ 时间: 2024-01-01 12:00:00 +``` + +#### 订单失败消息 +``` +❌ 订单创建失败 + +📊 订单信息: +• 市场: Market Title +• 方向: BUY +• 价格: 0.50 +• 数量: 100 USDC +• 账户: Account 1 (0x1234...5678) + +⚠️ 错误信息: 余额不足 + +⏰ 时间: 2024-01-01 12:00:00 +``` + +### 2.3 集成点设计 + +需要在以下位置集成通知功能: + +1. **CopyOrderTrackingService.createOrder()** + - 订单创建成功时发送成功通知 + - 订单创建失败时发送失败通知 + +2. **AccountService.createPositionSellOrder()** + - 卖出订单成功/失败时发送通知 + +3. **PolymarketClobService.createSignedOrder()** + - 手动订单创建成功/失败时发送通知(可选) + +### 2.4 异步处理 +- 使用 Kotlin Coroutines 异步发送消息 +- 不阻塞订单处理流程 +- 发送失败不影响订单处理结果 + +## 3. 配置设计 + +### 3.1 配置文件 +在 `application.properties` 中添加: + +```properties +# Telegram Bot 配置 +telegram.bot.enabled=true +telegram.bot.token=${TELEGRAM_BOT_TOKEN:} +telegram.bot.chat-id=${TELEGRAM_CHAT_ID:} # 单个用户(兼容旧配置) +telegram.bot.chat-ids=${TELEGRAM_CHAT_IDS:} # 多个用户,逗号分隔(推荐) +telegram.bot.timeout=5000 +``` + +**配置说明**: +- `telegram.bot.chat-id`: 单个用户 Chat ID(兼容旧配置) +- `telegram.bot.chat-ids`: 多个用户 Chat ID,用逗号分隔(如:`123456789,987654321`) +- 如果同时配置了 `chat-id` 和 `chat-ids`,会同时发送给所有用户 + +### 3.2 功能开关 +- 支持通过配置开启/关闭通知功能 +- 如果未配置 Token 或 Chat ID,自动禁用通知 + +## 4. 错误处理 + +### 4.1 发送失败处理 +- 记录错误日志,但不抛出异常 +- 不影响订单处理流程 +- 支持重试机制(可选) + +### 4.2 网络超时 +- 设置合理的超时时间(5秒) +- 超时后记录日志,不重试 + +## 5. 扩展功能(可选) + +### 5.1 消息类型扩展 +- 订单状态变更通知(filled, cancelled) +- 每日统计报告 +- 风险告警通知 + +### 5.2 多用户支持 +- **支持多个 Chat ID**:配置多个用户接收通知 + - 配置格式:`telegram.bot.chat-ids=123456789,987654321,111222333` + - 所有用户都会收到相同的通知 +- **按账户配置不同的通知接收者**(高级功能) + - 在账户表中添加 `telegram_chat_id` 字段 + - 不同账户的订单通知发送给不同的用户 + +### 5.3 消息模板 +- 支持自定义消息模板 +- 支持多语言消息 + +## 6. 实现步骤 + +1. **创建 TelegramNotificationService** + - 实现发送消息方法 + - 实现消息格式化方法 + +2. **创建 TelegramConfig** + - 读取配置 + - 验证配置有效性 + +3. **集成到订单处理服务** + - 在订单成功/失败时调用通知服务 + - 异步发送,不阻塞主流程 + +4. **添加配置项** + - 在 application.properties 中添加配置 + - 支持环境变量 + +5. **测试** + - 单元测试 + - 集成测试 + - 端到端测试 + diff --git a/frontend/index.html b/frontend/index.html index 1dc0095..e2ccd9b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + PolyHermes diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..05fb26f --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99c5492..1ba5481 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import SystemSettings from './pages/SystemSettings' import LanguageSettings from './pages/LanguageSettings' import ApiHealthStatus from './pages/ApiHealthStatus' import ProxySettings from './pages/ProxySettings' +import NotificationSettings from './pages/NotificationSettings' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -247,6 +248,7 @@ function App() { } /> } /> } /> + } /> {/* 默认重定向到登录页 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c6d7ab5..e6ec9d5 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -18,12 +18,14 @@ import { GithubOutlined, TwitterOutlined, GlobalOutlined, - CheckCircleOutlined + CheckCircleOutlined, + NotificationOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' import { removeToken } from '../utils' import { wsManager } from '../services/websocket' +import Logo from './Logo' const { Header, Content, Sider } = AntLayout @@ -133,6 +135,11 @@ const Layout: React.FC = ({ children }) => { key: '/system-settings/proxy', icon: , label: t('menu.proxy') + }, + { + key: '/system-settings/notifications', + icon: , + label: t('menu.notifications') } ] }, @@ -198,9 +205,10 @@ const Layout: React.FC = ({ children }) => { alignItems: 'center', justifyContent: 'space-between' }}> -
- PolyHermes -
+
= ({ + size = 'normal', + iconOnly = false, + darkMode = false, + className = '', + style = {} +}) => { + // 根据尺寸确定图标大小 + const iconSizes = { + small: 24, + normal: 32, + large: 48 + } + + // 根据尺寸确定文字大小 + const textSizes = { + small: 14, + normal: 18, + large: 24 + } + + const iconSize = iconSizes[size] + const textSize = textSizes[size] + + // 根据深色模式选择颜色 + const gradientColors = darkMode + ? { start: '#69c0ff', end: '#b37feb' } // 深色背景使用较亮的颜色 + : { start: '#1890ff', end: '#722ed1' } // 浅色背景使用标准颜色 + + const textColor = darkMode ? '#fff' : 'inherit' + + return ( +
+ {/* Logo 图标 */} + + {/* 渐变定义 */} + + + + + + + + {/* 主图标设计:双箭头连接,代表跟单交易 */} + {/* 左侧箭头(指向中心) */} + + + {/* 中心连接线(代表跟单连接) */} + + + {/* 右侧箭头(指向中心) */} + + + {/* 中心圆点(代表交易节点/数据同步点) */} + + + {/* 装饰性数据流弧线(代表实时数据同步) */} + + + + + {/* Logo 文字 */} + {!iconOnly && ( + + PolyHermes + + )} +
+ ) +} + +export default Logo + diff --git a/frontend/src/components/notifications/DiscordConfigForm.tsx b/frontend/src/components/notifications/DiscordConfigForm.tsx new file mode 100644 index 0000000..ebe0d6b --- /dev/null +++ b/frontend/src/components/notifications/DiscordConfigForm.tsx @@ -0,0 +1,49 @@ +import { Form, Input, Alert } from 'antd' +import { useTranslation } from 'react-i18next' + +interface DiscordConfigFormProps { + form: any +} + +/** + * Discord 配置表单组件 + */ +const DiscordConfigForm: React.FC = ({ form }) => { + const { t } = useTranslation() + + return ( + <> + +

{t('discordConfig.step1')}

+

{t('discordConfig.step2')}

+

{t('discordConfig.step3')}

+
+ } + type="info" + showIcon + style={{ fontSize: '12px', marginBottom: 16 }} + /> + + + + + + + + ) +} + +export default DiscordConfigForm + diff --git a/frontend/src/components/notifications/SlackConfigForm.tsx b/frontend/src/components/notifications/SlackConfigForm.tsx new file mode 100644 index 0000000..924ee62 --- /dev/null +++ b/frontend/src/components/notifications/SlackConfigForm.tsx @@ -0,0 +1,49 @@ +import { Form, Input, Alert } from 'antd' +import { useTranslation } from 'react-i18next' + +interface SlackConfigFormProps { + form: any +} + +/** + * Slack 配置表单组件 + */ +const SlackConfigForm: React.FC = ({ form }) => { + const { t } = useTranslation() + + return ( + <> + +

{t('slackConfig.step1')}

+

{t('slackConfig.step2')}

+

{t('slackConfig.step3')}

+ + } + type="info" + showIcon + style={{ fontSize: '12px', marginBottom: 16 }} + /> + + + + + + + + ) +} + +export default SlackConfigForm + diff --git a/frontend/src/components/notifications/TelegramConfigForm.tsx b/frontend/src/components/notifications/TelegramConfigForm.tsx new file mode 100644 index 0000000..3452ebe --- /dev/null +++ b/frontend/src/components/notifications/TelegramConfigForm.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react' +import { Form, Input, Alert, Button, Space, message } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { apiService } from '../../services/api' + +interface TelegramConfigFormProps { + form: any +} + +/** + * Telegram 配置表单组件 + */ +const TelegramConfigForm: React.FC = ({ form }) => { + const { t } = useTranslation() + const [loading, setLoading] = useState(false) + + /** + * 自动获取 Chat IDs + */ + const handleGetChatIds = async () => { + const botToken = form.getFieldValue(['config', 'botToken']) + if (!botToken || botToken.trim() === '') { + message.warning(t('notificationSettings.getChatIdsNoToken')) + return + } + + setLoading(true) + try { + const response = await apiService.notifications.getTelegramChatIds({ botToken: botToken.trim() }) + if (response.data.code === 0 && response.data.data) { + const chatIds = response.data.data + if (chatIds.length > 0) { + // 获取现有的 Chat IDs + const existingChatIds = form.getFieldValue(['config', 'chatIds']) || '' + const existingArray = typeof existingChatIds === 'string' + ? existingChatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id) + : Array.isArray(existingChatIds) ? existingChatIds : [] + + // 合并并去重 + const allChatIds = [...new Set([...existingArray, ...chatIds])] + form.setFieldsValue({ + config: { + ...form.getFieldValue('config'), + chatIds: allChatIds.join(',') + } + }) + message.success(t('notificationSettings.getChatIdsSuccess', { count: chatIds.length })) + } else { + message.warning(t('notificationSettings.getChatIdsNoMessage')) + } + } else { + message.error(response.data.msg || t('notificationSettings.getChatIdsFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.getChatIdsFailed')) + } finally { + setLoading(false) + } + } + + return ( + <> + +

+

+

+

{t('telegramConfig.step4')}

+

{t('telegramConfig.step5')}

+ + } + type="info" + showIcon + style={{ fontSize: '12px', marginBottom: 16 }} + /> + + + + } + loading={loading} + onClick={handleGetChatIds} + style={{ padding: 0, height: 'auto' }} + > + {t('notificationSettings.getChatIdsButton')} + + } + /> + + + + + + + + ) +} + +export default TelegramConfigForm + diff --git a/frontend/src/components/notifications/index.ts b/frontend/src/components/notifications/index.ts new file mode 100644 index 0000000..e6a1662 --- /dev/null +++ b/frontend/src/components/notifications/index.ts @@ -0,0 +1,4 @@ +export { default as TelegramConfigForm } from './TelegramConfigForm' +export { default as DiscordConfigForm } from './DiscordConfigForm' +export { default as SlackConfigForm } from './SlackConfigForm' + diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 8b0a18b..ba86045 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -203,6 +203,7 @@ "language": "Language", "apiHealth": "API Health", "proxy": "Proxy", + "notifications": "Notifications", "logout": "Logout", "logoutConfirm": "Confirm Logout", "logoutConfirmDesc": "Are you sure you want to logout?", @@ -428,5 +429,75 @@ "deleteSuccess": "Copy trading deleted successfully", "deleteFailed": "Failed to delete copy trading", "deleteConfirm": "Are you sure you want to delete this copy trading relationship?" + }, + "notificationSettings": { + "title": "Notification Settings", + "addConfig": "Add Configuration", + "editConfig": "Edit Configuration", + "configName": "Configuration Name", + "configNamePlaceholder": "e.g., My Telegram Bot", + "configNameRequired": "Please enter configuration name", + "type": "Notification Type", + "typeRequired": "Please select notification type", + "enabled": "Enabled Status", + "status": "Status", + "enabledStatus": "Enabled", + "disabledStatus": "Disabled", + "chatIds": "Chat IDs", + "chatIdsPlaceholder": "Chat IDs (click button above to auto-fetch, or enter manually, separate multiple with commas)", + "chatIdsRequired": "Please enter at least one Chat ID", + "chatIdsExtra": "Multiple Chat IDs separated by commas, e.g., 123456789,987654321. Click button above to auto-fetch (need to send message to bot first)", + "chatIdsCount": " recipients", + "chatIdsNotConfigured": "Not Configured", + "edit": "Edit", + "test": "Test", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete this configuration?", + "enableSuccess": "Enabled", + "disableSuccess": "Disabled", + "updateStatusFailed": "Failed to update", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Failed to delete", + "createSuccess": "Created successfully", + "createFailed": "Failed to create", + "updateSuccess": "Updated successfully", + "updateFailed": "Failed to update", + "fetchFailed": "Failed to get configuration list", + "testSuccess": "Test message sent successfully, please check Telegram", + "testFailed": "Failed to send test message", + "getChatIdsSuccess": "Successfully fetched {count} Chat ID(s)", + "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" + }, + "telegramConfig": { + "title": "Telegram Configuration Guide", + "step1": "Create a Telegram bot via @BotFather and get Bot Token", + "step2": "After entering Bot Token, click 'Get Chat ID' button to auto-fetch (need to send message to bot first)", + "step3": "Or manually get Chat ID via @userinfobot", + "step4": "Support multiple Chat IDs (separated by commas), all configured users will receive notifications", + "step5": "Telegram messages will be sent automatically when orders succeed or fail", + "botToken": "Bot Token", + "botTokenPlaceholder": "Bot Token (from @BotFather)", + "botTokenRequired": "Please enter Bot Token" + }, + "discordConfig": { + "title": "Discord Configuration Guide", + "step1": "Create a Webhook in Discord server", + "step2": "Copy Webhook URL and enter in configuration", + "step3": "Discord messages will be sent automatically when orders succeed or fail", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "Webhook URL (from Discord server settings)", + "webhookUrlRequired": "Please enter Webhook URL" + }, + "slackConfig": { + "title": "Slack Configuration Guide", + "step1": "Create an Incoming Webhook in Slack workspace", + "step2": "Copy Webhook URL and enter in configuration", + "step3": "Slack messages will be sent automatically when orders succeed or fail", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "Webhook URL (from Slack App settings)", + "webhookUrlRequired": "Please enter Webhook URL" } } diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 07caa13..afa1e00 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -56,14 +56,58 @@ "importAccount": "导入账户", "accountName": "账户名称", "walletAddress": "钱包地址", + "proxyAddress": "代理钱包地址", + "apiCredentials": "API 凭证", "balance": "余额", + "activeOrders": "活跃订单", + "action": "操作", "actions": "操作", + "detail": "详情", "edit": "编辑", "delete": "删除", "viewDetail": "查看详情", "deleteConfirm": "确定要删除这个账户吗?", + "deleteConfirmDesc": "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!", + "deleteConfirmDescSimple": "删除后无法恢复,请谨慎操作!", + "deleteConfirmOk": "确定删除", "deleteSuccess": "删除账户成功", "deleteFailed": "删除账户失败", + "copySuccess": "已复制到剪贴板", + "copyFailed": "复制失败", + "fullConfig": "完整配置", + "partialConfig": "部分配置", + "notConfigured": "未配置", + "totalBalance": "总余额", + "available": "可用", + "position": "仓位", + "refreshBalance": "刷新余额", + "refreshBalanceSuccess": "余额刷新成功", + "refreshBalanceFailed": "刷新余额失败", + "getDetailFailed": "获取账户详情失败", + "openDetailFailed": "打开详情失败", + "accountDetail": "账户详情", + "accountId": "账户ID", + "apiKey": "API Key", + "apiSecret": "API Secret", + "apiPassphrase": "API Passphrase", + "configured": "已配置", + "notConfiguredStatus": "未配置", + "configStatus": "配置状态", + "statistics": "交易统计", + "totalOrders": "总订单数", + "activeOrdersCount": "活跃订单数", + "completedOrders": "已完成订单数", + "positionCount": "持仓数量", + "totalPnl": "总盈亏", + "editAccount": "编辑账户", + "editTip": "编辑提示", + "editTipDesc": "API 凭证字段留空表示不修改。如需更新 API 凭证,请输入新值;如需保持原值不变,请留空。", + "accountNamePlaceholder": "账户名称(可选)", + "leaveEmptyToNotModify": "留空表示不修改", + "updateSuccess": "更新账户成功", + "updateFailed": "更新账户失败", + "getDetailFailedForEdit": "获取账户详情失败", + "loading": "加载中...", "fetchFailed": "获取账户列表失败" }, "accountImport": { @@ -104,6 +148,7 @@ "language": "语言", "apiHealth": "API健康", "proxy": "代理", + "notifications": "消息推送", "logout": "退出登录", "logoutConfirm": "确认退出", "logoutConfirmDesc": "确定要退出登录吗?", @@ -330,5 +375,75 @@ "deleteSuccess": "删除跟单成功", "deleteFailed": "删除跟单失败", "deleteConfirm": "确定要删除这个跟单关系吗?" + }, + "notificationSettings": { + "title": "消息推送设置", + "addConfig": "添加配置", + "editConfig": "编辑配置", + "configName": "配置名称", + "configNamePlaceholder": "例如:我的 Telegram 机器人", + "configNameRequired": "请输入配置名称", + "type": "推送类型", + "typeRequired": "请选择推送类型", + "enabled": "启用状态", + "status": "状态", + "enabledStatus": "已启用", + "disabledStatus": "已禁用", + "chatIds": "Chat IDs", + "chatIdsPlaceholder": "Chat IDs(点击上方按钮自动获取,或手动输入,多个用逗号分隔)", + "chatIdsRequired": "请输入至少一个 Chat ID", + "chatIdsExtra": "多个 Chat ID 用逗号分隔,例如:123456789,987654321。点击上方按钮可自动获取(需要先向机器人发送消息)", + "chatIdsCount": "个接收者", + "chatIdsNotConfigured": "未配置", + "edit": "编辑", + "test": "测试", + "delete": "删除", + "deleteConfirm": "确定要删除这个配置吗?", + "enableSuccess": "已启用", + "disableSuccess": "已禁用", + "updateStatusFailed": "更新失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "createSuccess": "创建成功", + "createFailed": "创建失败", + "updateSuccess": "更新成功", + "updateFailed": "更新失败", + "fetchFailed": "获取配置列表失败", + "testSuccess": "测试消息发送成功,请检查 Telegram", + "testFailed": "测试消息发送失败", + "getChatIdsSuccess": "成功获取 {count} 个 Chat ID", + "getChatIdsFailed": "获取 Chat IDs 失败", + "getChatIdsNoToken": "请先填写 Bot Token", + "getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试", + "getChatIdsButton": "获取 Chat ID" + }, + "telegramConfig": { + "title": "Telegram 配置说明", + "step1": "通过 @BotFather 创建 Telegram 机器人,获取 Bot Token", + "step2": "填写 Bot Token 后,点击\"获取 Chat ID\"按钮自动获取(需要先向机器人发送消息)", + "step3": "或通过 @userinfobot 手动获取 Chat ID", + "step4": "支持配置多个 Chat ID(用逗号分隔),所有配置的用户都会收到通知", + "step5": "订单成功或失败时会自动发送 Telegram 消息", + "botToken": "Bot Token", + "botTokenPlaceholder": "Bot Token(从 @BotFather 获取)", + "botTokenRequired": "请输入 Bot Token" + }, + "discordConfig": { + "title": "Discord 配置说明", + "step1": "在 Discord 服务器中创建 Webhook", + "step2": "复制 Webhook URL 并填入配置", + "step3": "订单成功或失败时会自动发送 Discord 消息", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "Webhook URL(从 Discord 服务器设置中获取)", + "webhookUrlRequired": "请输入 Webhook URL" + }, + "slackConfig": { + "title": "Slack 配置说明", + "step1": "在 Slack 工作区中创建 Incoming Webhook", + "step2": "复制 Webhook URL 并填入配置", + "step3": "订单成功或失败时会自动发送 Slack 消息", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)", + "webhookUrlRequired": "请输入 Webhook URL" } } diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index f1f6741..9e0f41e 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -203,6 +203,7 @@ "language": "語言", "apiHealth": "API健康", "proxy": "代理", + "notifications": "消息推送", "logout": "退出登錄", "logoutConfirm": "確認退出", "logoutConfirmDesc": "確定要退出登錄嗎?", @@ -428,5 +429,75 @@ "deleteSuccess": "刪除跟單成功", "deleteFailed": "刪除跟單失敗", "deleteConfirm": "確定要刪除這個跟單關係嗎?" + }, + "notificationSettings": { + "title": "消息推送設置", + "addConfig": "添加配置", + "editConfig": "編輯配置", + "configName": "配置名稱", + "configNamePlaceholder": "例如:我的 Telegram 機器人", + "configNameRequired": "請輸入配置名稱", + "type": "推送類型", + "typeRequired": "請選擇推送類型", + "enabled": "啟用狀態", + "status": "狀態", + "enabledStatus": "已啟用", + "disabledStatus": "已禁用", + "chatIds": "Chat IDs", + "chatIdsPlaceholder": "Chat IDs(點擊上方按鈕自動獲取,或手動輸入,多個用逗號分隔)", + "chatIdsRequired": "請輸入至少一個 Chat ID", + "chatIdsExtra": "多個 Chat ID 用逗號分隔,例如:123456789,987654321。點擊上方按鈕可自動獲取(需要先向機器人發送消息)", + "chatIdsCount": "個接收者", + "chatIdsNotConfigured": "未配置", + "edit": "編輯", + "test": "測試", + "delete": "刪除", + "deleteConfirm": "確定要刪除這個配置嗎?", + "enableSuccess": "已啟用", + "disableSuccess": "已禁用", + "updateStatusFailed": "更新失敗", + "deleteSuccess": "刪除成功", + "deleteFailed": "刪除失敗", + "createSuccess": "創建成功", + "createFailed": "創建失敗", + "updateSuccess": "更新成功", + "updateFailed": "更新失敗", + "fetchFailed": "獲取配置列表失敗", + "testSuccess": "測試消息發送成功,請檢查 Telegram", + "testFailed": "測試消息發送失敗", + "getChatIdsSuccess": "成功獲取 {count} 個 Chat ID", + "getChatIdsFailed": "獲取 Chat IDs 失敗", + "getChatIdsNoToken": "請先填寫 Bot Token", + "getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試", + "getChatIdsButton": "獲取 Chat ID" + }, + "telegramConfig": { + "title": "Telegram 配置說明", + "step1": "通過 @BotFather 創建 Telegram 機器人,獲取 Bot Token", + "step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)", + "step3": "或通過 @userinfobot 手動獲取 Chat ID", + "step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知", + "step5": "訂單成功或失敗時會自動發送 Telegram 消息", + "botToken": "Bot Token", + "botTokenPlaceholder": "Bot Token(從 @BotFather 獲取)", + "botTokenRequired": "請輸入 Bot Token" + }, + "discordConfig": { + "title": "Discord 配置說明", + "step1": "在 Discord 服務器中創建 Webhook", + "step2": "複製 Webhook URL 並填入配置", + "step3": "訂單成功或失敗時會自動發送 Discord 消息", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "Webhook URL(從 Discord 服務器設置中獲取)", + "webhookUrlRequired": "請輸入 Webhook URL" + }, + "slackConfig": { + "title": "Slack 配置說明", + "step1": "在 Slack 工作區中創建 Incoming Webhook", + "step2": "複製 Webhook URL 並填入配置", + "step3": "訂單成功或失敗時會自動發送 Slack 消息", + "webhookUrl": "Webhook URL", + "webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)", + "webhookUrlRequired": "請輸入 Webhook URL" } } diff --git a/frontend/src/pages/NotificationSettings.tsx b/frontend/src/pages/NotificationSettings.tsx new file mode 100644 index 0000000..24512b5 --- /dev/null +++ b/frontend/src/pages/NotificationSettings.tsx @@ -0,0 +1,406 @@ +import { useEffect, useState } from 'react' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, Switch } from 'antd' +import { PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { apiService } from '../services/api' +import type { NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types' +import { useMediaQuery } from 'react-responsive' +import { TelegramConfigForm, DiscordConfigForm, SlackConfigForm } from '../components/notifications' + +const { Title, Text } = Typography + +const NotificationSettings: React.FC = () => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [editingConfig, setEditingConfig] = useState(null) + const [form] = Form.useForm() + const [testLoading, setTestLoading] = useState(false) + + useEffect(() => { + fetchConfigs() + }, []) + + const fetchConfigs = async () => { + setLoading(true) + try { + const response = await apiService.notifications.list({ type: 'telegram' }) + if (response.data.code === 0 && response.data.data) { + setConfigs(response.data.data) + } else { + message.error(response.data.msg || t('notificationSettings.fetchFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.fetchFailed')) + } finally { + setLoading(false) + } + } + + const handleCreate = () => { + setEditingConfig(null) + form.resetFields() + form.setFieldsValue({ + type: 'telegram', + enabled: true, + config: { + botToken: '', + chatIds: [] + } + }) + setModalVisible(true) + } + + const handleEdit = (config: NotificationConfig) => { + setEditingConfig(config) + + // 处理配置数据:后端返回的是 NotificationConfigData.Telegram 结构 + // 结构可能是: { data: { botToken: string, chatIds: string[] } } 或直接 { botToken: string, chatIds: string[] } + let botToken = '' + let chatIds = '' + + if (config.config) { + // 检查是否是嵌套结构 (NotificationConfigData.Telegram) + if ('data' in config.config && config.config.data) { + const data = config.config.data as any + botToken = data.botToken || '' + if (data.chatIds) { + if (Array.isArray(data.chatIds)) { + chatIds = data.chatIds.join(',') + } else if (typeof data.chatIds === 'string') { + chatIds = data.chatIds + } + } + } else { + // 直接结构 (TelegramConfigData) + if ('botToken' in config.config) { + botToken = (config.config as any).botToken || '' + } + if ('chatIds' in config.config) { + const ids = (config.config as any).chatIds + if (Array.isArray(ids)) { + chatIds = ids.join(',') + } else if (typeof ids === 'string') { + chatIds = ids + } + } + } + } + + form.setFieldsValue({ + type: config.type, + name: config.name, + enabled: config.enabled, + config: { + botToken: botToken, + chatIds: chatIds + } + }) + setModalVisible(true) + } + + const handleDelete = async (id: number) => { + try { + const response = await apiService.notifications.delete({ id }) + if (response.data.code === 0) { + message.success(t('notificationSettings.deleteSuccess')) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.deleteFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.deleteFailed')) + } + } + + const handleUpdateEnabled = async (id: number, enabled: boolean) => { + try { + const response = await apiService.notifications.updateEnabled({ id, enabled }) + if (response.data.code === 0) { + message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess')) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.updateStatusFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.updateStatusFailed')) + } + } + + const handleTest = async () => { + setTestLoading(true) + try { + const response = await apiService.notifications.test({ message: '这是一条测试消息' }) + if (response.data.code === 0 && response.data.data) { + message.success(t('notificationSettings.testSuccess')) + } else { + message.error(response.data.msg || t('notificationSettings.testFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.testFailed')) + } finally { + setTestLoading(false) + } + } + + const handleSubmit = async () => { + try { + const values = await form.validateFields() + + // 处理 chatIds:如果是字符串,转换为数组 + const chatIds = typeof values.config.chatIds === 'string' + ? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id) + : values.config.chatIds || [] + + const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = { + type: values.type, + name: values.name, + enabled: values.enabled, + config: { + botToken: values.config.botToken, + chatIds: chatIds + } + } + + if (editingConfig?.id) { + // 更新 + const updateData = { + ...configData, + id: editingConfig.id + } as NotificationConfigUpdateRequest + + const response = await apiService.notifications.update(updateData) + if (response.data.code === 0) { + message.success(t('notificationSettings.updateSuccess')) + setModalVisible(false) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.updateFailed')) + } + } else { + // 创建 + const response = await apiService.notifications.create(configData) + if (response.data.code === 0) { + message.success(t('notificationSettings.createSuccess')) + setModalVisible(false) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.createFailed')) + } + } + } catch (error: any) { + if (error.errorFields) { + // 表单验证错误 + return + } + message.error(error.message || t('message.error')) + } + } + + /** + * 根据推送类型获取配置表单组件 + */ + const getConfigFormComponent = (type: string) => { + switch (type?.toLowerCase()) { + case 'telegram': + return + case 'discord': + return + case 'slack': + return + default: + return null + } + } + + const columns = [ + { + title: t('notificationSettings.configName'), + dataIndex: 'name', + key: 'name', + }, + { + title: t('notificationSettings.type'), + dataIndex: 'type', + key: 'type', + render: (type: string) => {type.toUpperCase()} + }, + { + title: t('notificationSettings.status'), + dataIndex: 'enabled', + key: 'enabled', + render: (enabled: boolean) => ( + + {enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')} + + ) + }, + { + title: t('notificationSettings.chatIds'), + key: 'chatIds', + render: (_: any, record: NotificationConfig) => { + // 处理配置数据:后端返回的是 NotificationConfigData.Telegram 结构 + // 结构可能是: { data: { botToken: string, chatIds: string[] } } 或直接 { botToken: string, chatIds: string[] } + let chatIds: string[] = [] + + if (record.config) { + // 检查是否是嵌套结构 (NotificationConfigData.Telegram) + if ('data' in record.config && record.config.data) { + const data = (record.config as any).data + if (data.chatIds) { + if (Array.isArray(data.chatIds)) { + chatIds = data.chatIds.filter((id: any) => id && String(id).trim()) + } else if (typeof data.chatIds === 'string') { + chatIds = data.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id) + } + } + } else if ('chatIds' in record.config) { + // 直接结构 (TelegramConfigData) + const ids: any = (record.config as any).chatIds + if (Array.isArray(ids)) { + chatIds = ids.filter((id: any) => id && String(id).trim()) + } else if (typeof ids === 'string') { + chatIds = (ids as string).split(',').map((id: string) => id.trim()).filter((id: string) => id) + } + } + } + + return chatIds.length > 0 ? ( + + {chatIds.join(', ')} + + ) : ( + {t('notificationSettings.chatIdsNotConfigured')} + ) + } + }, + { + title: t('common.actions'), + key: 'action', + width: isMobile ? 120 : 200, + render: (_: any, record: NotificationConfig) => ( + + + handleUpdateEnabled(record.id!, checked)} + /> + + handleDelete(record.id!)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + + ) + } + ] + + return ( +
+ +
+ {t('notificationSettings.title')} + +
+ + + + + setModalVisible(false)} + width={isMobile ? '90%' : 600} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > +
+ + + + + + + + + + + + + {/* 根据推送类型动态渲染配置表单组件 */} + { + // 监听 type 变化,以及编辑时 config 数据的变化 + return prevValues.type !== currentValues.type || + prevValues.config !== currentValues.config + }}> + {() => { + const currentType = form.getFieldValue('type') || 'telegram' + return getConfigFormComponent(currentType) + }} + + +
+ + ) +} + +export default NotificationSettings + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f80c8f1..008803b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance, AxiosError } from 'axios' -import type { ApiResponse } from '../types' +import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types' import { getToken, setToken, removeToken } from '../utils' import { wsManager } from './websocket' import i18n from '../i18n/config' @@ -474,6 +474,59 @@ export const apiService = { responseTime?: number }> }>>('/proxy-config/api-health-check', {}) + }, + + /** + * 消息推送配置 API + */ + notifications: { + /** + * 获取配置列表 + */ + list: (data?: { type?: string }) => + apiClient.post>('/notifications/configs/list', data || {}), + + /** + * 获取配置详情 + */ + detail: (data: { id: number }) => + apiClient.post>('/notifications/configs/detail', data), + + /** + * 创建配置 + */ + create: (data: NotificationConfigRequest) => + apiClient.post>('/notifications/configs/create', data), + + /** + * 更新配置 + */ + update: (data: NotificationConfigUpdateRequest) => + apiClient.post>('/notifications/configs/update', data), + + /** + * 更新启用状态 + */ + updateEnabled: (data: { id: number; enabled: boolean }) => + apiClient.post>('/notifications/configs/update-enabled', data), + + /** + * 删除配置 + */ + delete: (data: { id: number }) => + apiClient.post>('/notifications/configs/delete', data), + + /** + * 测试通知 + */ + test: (data?: { message?: string }) => + apiClient.post>('/notifications/test', data || {}), + + /** + * 获取 Telegram Chat IDs + */ + getTelegramChatIds: (data: { botToken: string }) => + apiClient.post>('/notifications/telegram/get-chat-ids', data) } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5ed39fb..4ba6b1d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -600,3 +600,49 @@ export interface OrderTrackingRequest { buyOrderId?: string } +/** + * 消息推送配置 + */ +export interface NotificationConfig { + id?: number + type: string // telegram、discord、slack 等 + name: string // 配置名称 + enabled: boolean // 是否启用 + config: { + botToken?: string // Telegram Bot Token + chatIds?: string[] // Telegram Chat IDs + [key: string]: any // 其他配置字段 + } + createdAt?: number + updatedAt?: number +} + +/** + * 通知配置请求 + */ +export interface NotificationConfigRequest { + type: string + name: string + enabled?: boolean + config: { + botToken?: string + chatIds?: string[] | string // 支持数组或逗号分隔的字符串 + [key: string]: any + } +} + +/** + * 通知配置更新请求 + */ +export interface NotificationConfigUpdateRequest { + id: number + type: string + name: string + enabled?: boolean + config: { + botToken?: string + chatIds?: string[] | string + [key: string]: any + } +} +