feat: 完善 Telegram 推送通知功能
- 推送模板优化: - 优先使用账户名称而不是钱包地址 - 使用市场标题而不是16进制ID - 添加可点击的市场链接(支持 slug 和 conditionId) - 添加市场方向(outcome)显示 - 数量和价格从订单详情API获取实际值 - 失败通知只显示后端返回的msg,不显示完整堆栈 - 多语言支持: - 后端推送消息支持多语言(使用前端最后请求的语言) - 添加所有 ErrorCode 的多语言资源文件(中文简体、繁体、英文) - 通知消息文本全部使用多语言资源 - 功能改进: - 从订单详情获取实际的 side、price、size、outcome - 支持买入/卖出方向的多语言显示 - 错误信息优化,只显示后端返回的错误消息
This commit is contained in:
@@ -535,3 +535,122 @@ if (targetOutcomeIndex >= 0) {
|
|||||||
// 使用 targetOutcomeIndex 进行判断
|
// 使用 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<ApiResponse<List<NotificationConfigDto>>> {
|
||||||
|
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<ApiResponse<NotificationConfigDto>> {
|
||||||
|
if (request.id == null) {
|
||||||
|
// ✅ 正确:使用 ErrorCode
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.PARAM_EMPTY,
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// ... 业务逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -330,3 +330,49 @@ const ResponsiveTable: React.FC = () => {
|
|||||||
- ❌ 禁止使用过小的触摸目标(< 44x44px)
|
- ❌ 禁止使用过小的触摸目标(< 44x44px)
|
||||||
- ❌ 禁止忽略移动端性能优化
|
- ❌ 禁止忽略移动端性能优化
|
||||||
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
|
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
|
||||||
|
|
||||||
|
### 多语言使用规范
|
||||||
|
- **必须**使用多语言(i18n)进行所有文本显示
|
||||||
|
- **禁止**在组件中硬编码中文或英文文本
|
||||||
|
- **必须**使用 `useTranslation` Hook 获取翻译函数
|
||||||
|
- **必须**在语言文件中定义所有显示的文本
|
||||||
|
- **必须**支持至少中文(简体/繁体)和英文
|
||||||
|
|
||||||
|
#### 多语言使用示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 正确:使用多语言
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const MyComponent: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t('page.title')}</h1>
|
||||||
|
<Button>{t('common.save')}</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 错误:硬编码文本
|
||||||
|
const MyComponent: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>页面标题</h1> {/* 禁止硬编码 */}
|
||||||
|
<Button>保存</Button> {/* 禁止硬编码 */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 语言文件结构
|
||||||
|
- 语言文件位置:`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')` 获取翻译
|
||||||
|
|||||||
@@ -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<ApiResponse<List<NotificationConfigDto>>> {
|
||||||
|
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<ApiResponse<NotificationConfigDto>> {
|
||||||
|
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<ApiResponse<NotificationConfigDto>> {
|
||||||
|
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<ApiResponse<NotificationConfigDto>> {
|
||||||
|
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<ApiResponse<NotificationConfigDto>> {
|
||||||
|
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<ApiResponse<Unit>> {
|
||||||
|
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<ApiResponse<Boolean>> {
|
||||||
|
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<ApiResponse<List<String>>> {
|
||||||
|
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<String, Any>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新启用状态请求
|
||||||
|
*/
|
||||||
|
data class NotificationConfigUpdateEnabledRequest(
|
||||||
|
val id: Long,
|
||||||
|
val enabled: Boolean? = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除配置请求
|
||||||
|
*/
|
||||||
|
data class NotificationConfigDeleteRequest(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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<String> // 多个 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<String, Any> // 配置信息(JSON 对象)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通知请求
|
||||||
|
*/
|
||||||
|
data class TestNotificationRequest(
|
||||||
|
val configId: Long? = null, // 如果提供,使用指定配置;否则使用所有启用的配置
|
||||||
|
val message: String? = null // 测试消息内容
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
|
||||||
@@ -132,19 +132,34 @@ enum class ErrorCode(
|
|||||||
POSITION_INSUFFICIENT(4503, "仓位不足", "error.position_insufficient"),
|
POSITION_INSUFFICIENT(4503, "仓位不足", "error.position_insufficient"),
|
||||||
POSITION_ALREADY_REDEEMED(4504, "仓位已赎回", "error.position_already_redeemed"),
|
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)
|
// 账户相关业务错误 (4601-4699)
|
||||||
ACCOUNT_ALREADY_EXISTS(4601, "账户已存在", "error.account_already_exists"),
|
ACCOUNT_ALREADY_EXISTS(4601, "账户已存在", "error.account_already_exists"),
|
||||||
ACCOUNT_IS_DEFAULT(4602, "账户已是默认账户", "error.account_is_default"),
|
ACCOUNT_IS_DEFAULT(4702, "账户已是默认账户", "error.account_is_default"),
|
||||||
ACCOUNT_HAS_ACTIVE_ORDERS(4603, "账户有活跃订单", "error.account_has_active_orders"),
|
ACCOUNT_HAS_ACTIVE_ORDERS(4703, "账户有活跃订单", "error.account_has_active_orders"),
|
||||||
ACCOUNT_IS_LAST_ONE(4604, "不能删除最后一个账户", "error.account_is_last_one"),
|
ACCOUNT_IS_LAST_ONE(4704, "不能删除最后一个账户", "error.account_is_last_one"),
|
||||||
ACCOUNT_API_KEY_CREATE_FAILED(4605, "自动获取 API Key 失败", "error.account_api_key_create_failed"),
|
ACCOUNT_API_KEY_CREATE_FAILED(4705, "自动获取 API Key 失败", "error.account_api_key_create_failed"),
|
||||||
ACCOUNT_PROXY_ADDRESS_FETCH_FAILED(4606, "获取代理地址失败", "error.account_proxy_address_fetch_failed"),
|
ACCOUNT_PROXY_ADDRESS_FETCH_FAILED(4706, "获取代理地址失败", "error.account_proxy_address_fetch_failed"),
|
||||||
ACCOUNT_BALANCE_FETCH_FAILED(4607, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||||
ACCOUNT_POSITIONS_FETCH_FAILED(4608, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||||
|
|
||||||
// 统计相关 (4701-4799)
|
// 统计相关 (4801-4899)
|
||||||
STATISTICS_FETCH_FAILED(4701, "获取统计信息失败", "error.statistics_fetch_failed"),
|
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||||
ORDER_LIST_FETCH_FAILED(4702, "查询订单列表失败", "error.order_list_fetch_failed"),
|
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
||||||
|
|
||||||
// ==================== 服务器内部错误 (5001-5999) ====================
|
// ==================== 服务器内部错误 (5001-5999) ====================
|
||||||
SERVER_ERROR(5001, "服务器内部错误", "error.server.error"),
|
SERVER_ERROR(5001, "服务器内部错误", "error.server.error"),
|
||||||
|
|||||||
+13
@@ -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<NotificationConfig, Long> {
|
||||||
|
fun findByType(type: String): List<NotificationConfig>
|
||||||
|
fun findByTypeAndEnabled(type: String, enabled: Boolean): List<NotificationConfig>
|
||||||
|
fun findByIdAndType(id: Long, type: String): NotificationConfig?
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
|
|||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import com.wrbug.polymarketbot.util.eq
|
import com.wrbug.polymarketbot.util.eq
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -27,10 +27,14 @@ class AccountService(
|
|||||||
private val apiKeyService: PolymarketApiKeyService,
|
private val apiKeyService: PolymarketApiKeyService,
|
||||||
private val orderPushService: OrderPushService,
|
private val orderPushService: OrderPushService,
|
||||||
private val orderSigningService: OrderSigningService,
|
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 logger = LoggerFactory.getLogger(AccountService::class.java)
|
||||||
|
|
||||||
|
// 协程作用域(用于异步发送通知)
|
||||||
|
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// 市价单价格调整系数(在最优价基础上调整,确保更快成交)
|
// 市价单价格调整系数(在最优价基础上调整,确保更快成交)
|
||||||
// 市价买单:bestAsk + BUY_PRICE_ADJUSTMENT(加价,确保能立即成交)
|
// 市价买单:bestAsk + BUY_PRICE_ADJUSTMENT(加价,确保能立即成交)
|
||||||
@@ -859,9 +863,60 @@ class AccountService(
|
|||||||
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
||||||
val response = orderResponse.body()!!
|
val response = orderResponse.body()!!
|
||||||
if (response.success) {
|
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(
|
Result.success(
|
||||||
PositionSellResponse(
|
PositionSellResponse(
|
||||||
orderId = response.orderId ?: "",
|
orderId = orderId,
|
||||||
marketId = request.marketId,
|
marketId = request.marketId,
|
||||||
side = request.side,
|
side = request.side,
|
||||||
orderType = request.orderType,
|
orderType = request.orderType,
|
||||||
@@ -875,6 +930,54 @@ class AccountService(
|
|||||||
val errorMsg = response.errorMsg ?: "未知错误"
|
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"
|
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)
|
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))
|
Result.failure(Exception(fullErrorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} 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 ""}"
|
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)
|
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))
|
Result.failure(Exception(fullErrorMsg))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
+128
-2
@@ -7,7 +7,7 @@ import com.wrbug.polymarketbot.entity.*
|
|||||||
import com.wrbug.polymarketbot.repository.*
|
import com.wrbug.polymarketbot.repository.*
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.*
|
import com.wrbug.polymarketbot.util.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -34,11 +34,15 @@ class CopyOrderTrackingService(
|
|||||||
private val orderSigningService: OrderSigningService,
|
private val orderSigningService: OrderSigningService,
|
||||||
private val blockchainService: BlockchainService,
|
private val blockchainService: BlockchainService,
|
||||||
private val retrofitFactory: RetrofitFactory,
|
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 logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||||
|
|
||||||
|
// 协程作用域(用于异步发送通知)
|
||||||
|
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密账户私钥
|
* 解密账户私钥
|
||||||
*/
|
*/
|
||||||
@@ -301,6 +305,54 @@ class CopyOrderTrackingService(
|
|||||||
errorMessage = errorMsg,
|
errorMessage = errorMsg,
|
||||||
retryCount = 1 // 已重试一次
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +376,80 @@ class CopyOrderTrackingService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
copyOrderTrackingRepository.save(tracking)
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
|
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
|
||||||
// 继续处理下一个跟单关系
|
// 继续处理下一个跟单关系
|
||||||
|
|||||||
@@ -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<NotificationConfigDto> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
notificationConfigRepository.findAll().map { entityToDto(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类型获取配置
|
||||||
|
*/
|
||||||
|
suspend fun getConfigsByType(type: String): List<NotificationConfigDto> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
notificationConfigRepository.findByType(type).map { entityToDto(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取启用的配置(按类型)
|
||||||
|
*/
|
||||||
|
suspend fun getEnabledConfigsByType(type: String): List<NotificationConfigDto> {
|
||||||
|
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<NotificationConfigDto> {
|
||||||
|
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<NotificationConfigDto> {
|
||||||
|
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<NotificationConfigDto> {
|
||||||
|
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<Unit> {
|
||||||
|
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<String, Any>) {
|
||||||
|
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<String, Any>
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+636
@@ -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<List<String>> {
|
||||||
|
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<String>()
|
||||||
|
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) {
|
||||||
|
"<a href=\"$marketLink\">$escapedMarketTitle</a>"
|
||||||
|
} else {
|
||||||
|
escapedMarketTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示市场方向(outcome)
|
||||||
|
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
|
||||||
|
val escapedOutcome = outcome.replace("<", "<").replace(">", ">")
|
||||||
|
"\n• $outcomeLabel: <b>$escapedOutcome</b>"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
✅ <b>$orderCreatedSuccess</b>
|
||||||
|
|
||||||
|
📊 <b>$orderInfo:</b>
|
||||||
|
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||||
|
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||||
|
• $sideLabel: <b>$sideDisplay</b>
|
||||||
|
• $priceLabel: <code>$price</code>
|
||||||
|
• $quantityLabel: <code>$size</code> shares
|
||||||
|
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||||
|
• $accountLabel: $escapedAccountInfo
|
||||||
|
|
||||||
|
⏰ $timeLabel: <code>$time</code>
|
||||||
|
""".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) {
|
||||||
|
"<a href=\"$marketLink\">$escapedMarketTitle</a>"
|
||||||
|
} else {
|
||||||
|
escapedMarketTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示市场方向(outcome)
|
||||||
|
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
|
||||||
|
val escapedOutcome = outcome.replace("<", "<").replace(">", ">")
|
||||||
|
"\n• $outcomeLabel: <b>$escapedOutcome</b>"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
❌ <b>$orderCreatedFailed</b>
|
||||||
|
|
||||||
|
📊 <b>$orderInfo:</b>
|
||||||
|
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||||
|
• $sideLabel: <b>$sideDisplay</b>
|
||||||
|
• $priceLabel: <code>$price</code>
|
||||||
|
• $quantityLabel: <code>$size</code> shares
|
||||||
|
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||||
|
• $accountLabel: $escapedAccountInfo
|
||||||
|
|
||||||
|
⚠️ <b>$errorInfo:</b>
|
||||||
|
<code>$escapedErrorMessage</code>
|
||||||
|
|
||||||
|
⏰ $timeLabel: <code>$time</code>
|
||||||
|
""".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)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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/..."
|
||||||
|
-- }
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=订单匹配失败
|
||||||
|
|||||||
@@ -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=訂單匹配失敗
|
||||||
|
|||||||
@@ -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<YOUR_BOT_TOKEN>/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<YOUR_BOT_TOKEN>/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"chat_id": "<YOUR_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<YOUR_BOT_TOKEN>/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 是私密信息,不要公开分享
|
||||||
|
|
||||||
@@ -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. **测试**
|
||||||
|
- 单元测试
|
||||||
|
- 集成测试
|
||||||
|
- 端到端测试
|
||||||
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>PolyHermes</title>
|
<title>PolyHermes</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1890ff" />
|
||||||
|
<stop offset="100%" stop-color="#722ed1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 主图标设计:双箭头连接,代表跟单交易 -->
|
||||||
|
<!-- 左侧箭头(指向中心) -->
|
||||||
|
<path
|
||||||
|
d="M 16 32 L 8 24 L 8 40 Z"
|
||||||
|
fill="url(#logoGradient)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 中心连接线(代表跟单连接) -->
|
||||||
|
<line
|
||||||
|
x1="20"
|
||||||
|
y1="32"
|
||||||
|
x2="44"
|
||||||
|
y2="32"
|
||||||
|
stroke="url(#logoGradient)"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 右侧箭头(指向中心) -->
|
||||||
|
<path
|
||||||
|
d="M 48 32 L 56 24 L 56 40 Z"
|
||||||
|
fill="url(#logoGradient)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 中心圆点(代表交易节点/数据同步点) -->
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
r="5"
|
||||||
|
fill="url(#logoGradient)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 装饰性数据流弧线(代表实时数据同步) -->
|
||||||
|
<path
|
||||||
|
d="M 20 20 Q 32 14 44 20"
|
||||||
|
stroke="url(#logoGradient)"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 20 44 Q 32 50 44 44"
|
||||||
|
stroke="url(#logoGradient)"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -32,6 +32,7 @@ import SystemSettings from './pages/SystemSettings'
|
|||||||
import LanguageSettings from './pages/LanguageSettings'
|
import LanguageSettings from './pages/LanguageSettings'
|
||||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||||
import ProxySettings from './pages/ProxySettings'
|
import ProxySettings from './pages/ProxySettings'
|
||||||
|
import NotificationSettings from './pages/NotificationSettings'
|
||||||
import { wsManager } from './services/websocket'
|
import { wsManager } from './services/websocket'
|
||||||
import type { OrderPushMessage } from './types'
|
import type { OrderPushMessage } from './types'
|
||||||
import { apiService } from './services/api'
|
import { apiService } from './services/api'
|
||||||
@@ -247,6 +248,7 @@ function App() {
|
|||||||
<Route path="/system-settings/language" element={<ProtectedRoute><LanguageSettings /></ProtectedRoute>} />
|
<Route path="/system-settings/language" element={<ProtectedRoute><LanguageSettings /></ProtectedRoute>} />
|
||||||
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||||
<Route path="/system-settings/proxy" element={<ProtectedRoute><ProxySettings /></ProtectedRoute>} />
|
<Route path="/system-settings/proxy" element={<ProtectedRoute><ProxySettings /></ProtectedRoute>} />
|
||||||
|
<Route path="/system-settings/notifications" element={<ProtectedRoute><NotificationSettings /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* 默认重定向到登录页 */}
|
{/* 默认重定向到登录页 */}
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ import {
|
|||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
TwitterOutlined,
|
TwitterOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
CheckCircleOutlined
|
CheckCircleOutlined,
|
||||||
|
NotificationOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { removeToken } from '../utils'
|
import { removeToken } from '../utils'
|
||||||
import { wsManager } from '../services/websocket'
|
import { wsManager } from '../services/websocket'
|
||||||
|
import Logo from './Logo'
|
||||||
|
|
||||||
const { Header, Content, Sider } = AntLayout
|
const { Header, Content, Sider } = AntLayout
|
||||||
|
|
||||||
@@ -133,6 +135,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
key: '/system-settings/proxy',
|
key: '/system-settings/proxy',
|
||||||
icon: <LinkOutlined />,
|
icon: <LinkOutlined />,
|
||||||
label: t('menu.proxy')
|
label: t('menu.proxy')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/system-settings/notifications',
|
||||||
|
icon: <NotificationOutlined />,
|
||||||
|
label: t('menu.notifications')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -198,9 +205,10 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between'
|
justifyContent: 'space-between'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
|
<Logo
|
||||||
PolyHermes
|
size="normal"
|
||||||
</div>
|
darkMode={true}
|
||||||
|
/>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/WrBug/PolyHermes"
|
href="https://github.com/WrBug/PolyHermes"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
/**
|
||||||
|
* Logo 尺寸
|
||||||
|
* @default 'normal'
|
||||||
|
*/
|
||||||
|
size?: 'small' | 'normal' | 'large'
|
||||||
|
/**
|
||||||
|
* 是否只显示图标(不显示文字)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
iconOnly?: boolean
|
||||||
|
/**
|
||||||
|
* 是否使用深色模式(用于深色背景)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
darkMode?: boolean
|
||||||
|
/**
|
||||||
|
* 自定义样式类名
|
||||||
|
*/
|
||||||
|
className?: string
|
||||||
|
/**
|
||||||
|
* 自定义样式
|
||||||
|
*/
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PolyHermes Logo 组件
|
||||||
|
*
|
||||||
|
* 设计理念:
|
||||||
|
* - Hermes(赫尔墨斯)是希腊神话中的信使神,代表快速传递和连接
|
||||||
|
* - 结合交易元素(箭头、连接线)体现跟单交易的核心功能
|
||||||
|
* - 现代简洁的设计风格,适配深色和浅色背景
|
||||||
|
*/
|
||||||
|
const Logo: React.FC<LogoProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`polyhermes-logo ${className}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo 图标 */}
|
||||||
|
<svg
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{/* 渐变定义 */}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`logoGradient-${darkMode ? 'dark' : 'light'}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={gradientColors.start} />
|
||||||
|
<stop offset="100%" stopColor={gradientColors.end} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* 主图标设计:双箭头连接,代表跟单交易 */}
|
||||||
|
{/* 左侧箭头(指向中心) */}
|
||||||
|
<path
|
||||||
|
d="M 16 32 L 8 24 L 8 40 Z"
|
||||||
|
fill={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 中心连接线(代表跟单连接) */}
|
||||||
|
<line
|
||||||
|
x1="20"
|
||||||
|
y1="32"
|
||||||
|
x2="44"
|
||||||
|
y2="32"
|
||||||
|
stroke={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧箭头(指向中心) */}
|
||||||
|
<path
|
||||||
|
d="M 48 32 L 56 24 L 56 40 Z"
|
||||||
|
fill={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 中心圆点(代表交易节点/数据同步点) */}
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
r="5"
|
||||||
|
fill={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性数据流弧线(代表实时数据同步) */}
|
||||||
|
<path
|
||||||
|
d="M 20 20 Q 32 14 44 20"
|
||||||
|
stroke={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 20 44 Q 32 50 44 44"
|
||||||
|
stroke={`url(#logoGradient-${darkMode ? 'dark' : 'light'})`}
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Logo 文字 */}
|
||||||
|
{!iconOnly && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: `${textSize}px`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: darkMode
|
||||||
|
? 'linear-gradient(135deg, #69c0ff 0%, #b37feb 100%)'
|
||||||
|
: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PolyHermes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logo
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Form, Input, Alert } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface DiscordConfigFormProps {
|
||||||
|
form: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord 配置表单组件
|
||||||
|
*/
|
||||||
|
const DiscordConfigForm: React.FC<DiscordConfigFormProps> = ({ form }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
message={t('discordConfig.title')}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '13px', lineHeight: '1.8' }}>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('discordConfig.step1')}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('discordConfig.step2')}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('discordConfig.step3')}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ fontSize: '12px', marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('notificationSettings.chatIds')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name={['config', 'webhookUrl']}
|
||||||
|
rules={[{ required: true, message: t('discordConfig.webhookUrlRequired') }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={t('discordConfig.webhookUrlPlaceholder')}
|
||||||
|
addonBefore={t('discordConfig.webhookUrl')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscordConfigForm
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Form, Input, Alert } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface SlackConfigFormProps {
|
||||||
|
form: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slack 配置表单组件
|
||||||
|
*/
|
||||||
|
const SlackConfigForm: React.FC<SlackConfigFormProps> = ({ form }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
message={t('slackConfig.title')}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '13px', lineHeight: '1.8' }}>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('slackConfig.step1')}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('slackConfig.step2')}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('slackConfig.step3')}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ fontSize: '12px', marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('notificationSettings.chatIds')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name={['config', 'webhookUrl']}
|
||||||
|
rules={[{ required: true, message: t('slackConfig.webhookUrlRequired') }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={t('slackConfig.webhookUrlPlaceholder')}
|
||||||
|
addonBefore={t('slackConfig.webhookUrl')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SlackConfigForm
|
||||||
|
|
||||||
@@ -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<TelegramConfigFormProps> = ({ 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 (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
message={t('telegramConfig.title')}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '13px', lineHeight: '1.8' }}>
|
||||||
|
<p style={{ margin: '4px 0' }} dangerouslySetInnerHTML={{ __html: `1. ${t('telegramConfig.step1')}` }} />
|
||||||
|
<p style={{ margin: '4px 0' }} dangerouslySetInnerHTML={{ __html: `2. ${t('telegramConfig.step2')}` }} />
|
||||||
|
<p style={{ margin: '4px 0' }} dangerouslySetInnerHTML={{ __html: `3. ${t('telegramConfig.step3')}` }} />
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('telegramConfig.step4')}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}>{t('telegramConfig.step5')}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ fontSize: '12px', marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('notificationSettings.chatIds')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name={['config', 'botToken']}
|
||||||
|
rules={[{ required: true, message: t('telegramConfig.botTokenRequired') }]}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder={t('telegramConfig.botTokenPlaceholder')}
|
||||||
|
addonBefore={t('telegramConfig.botToken')}
|
||||||
|
addonAfter={
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleGetChatIds}
|
||||||
|
style={{ padding: 0, height: 'auto' }}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.getChatIdsButton')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={['config', 'chatIds']}
|
||||||
|
rules={[{ required: true, message: t('notificationSettings.chatIdsRequired') }]}
|
||||||
|
extra={t('notificationSettings.chatIdsExtra')}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder={t('notificationSettings.chatIdsPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TelegramConfigForm
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as TelegramConfigForm } from './TelegramConfigForm'
|
||||||
|
export { default as DiscordConfigForm } from './DiscordConfigForm'
|
||||||
|
export { default as SlackConfigForm } from './SlackConfigForm'
|
||||||
|
|
||||||
@@ -203,6 +203,7 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"apiHealth": "API Health",
|
"apiHealth": "API Health",
|
||||||
"proxy": "Proxy",
|
"proxy": "Proxy",
|
||||||
|
"notifications": "Notifications",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"logoutConfirm": "Confirm Logout",
|
"logoutConfirm": "Confirm Logout",
|
||||||
"logoutConfirmDesc": "Are you sure you want to logout?",
|
"logoutConfirmDesc": "Are you sure you want to logout?",
|
||||||
@@ -428,5 +429,75 @@
|
|||||||
"deleteSuccess": "Copy trading deleted successfully",
|
"deleteSuccess": "Copy trading deleted successfully",
|
||||||
"deleteFailed": "Failed to delete copy trading",
|
"deleteFailed": "Failed to delete copy trading",
|
||||||
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?"
|
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?"
|
||||||
|
},
|
||||||
|
"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 <strong>@BotFather</strong> 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 <strong>@userinfobot</strong>",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,14 +56,58 @@
|
|||||||
"importAccount": "导入账户",
|
"importAccount": "导入账户",
|
||||||
"accountName": "账户名称",
|
"accountName": "账户名称",
|
||||||
"walletAddress": "钱包地址",
|
"walletAddress": "钱包地址",
|
||||||
|
"proxyAddress": "代理钱包地址",
|
||||||
|
"apiCredentials": "API 凭证",
|
||||||
"balance": "余额",
|
"balance": "余额",
|
||||||
|
"activeOrders": "活跃订单",
|
||||||
|
"action": "操作",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
|
"detail": "详情",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"viewDetail": "查看详情",
|
"viewDetail": "查看详情",
|
||||||
"deleteConfirm": "确定要删除这个账户吗?",
|
"deleteConfirm": "确定要删除这个账户吗?",
|
||||||
|
"deleteConfirmDesc": "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!",
|
||||||
|
"deleteConfirmDescSimple": "删除后无法恢复,请谨慎操作!",
|
||||||
|
"deleteConfirmOk": "确定删除",
|
||||||
"deleteSuccess": "删除账户成功",
|
"deleteSuccess": "删除账户成功",
|
||||||
"deleteFailed": "删除账户失败",
|
"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": "获取账户列表失败"
|
"fetchFailed": "获取账户列表失败"
|
||||||
},
|
},
|
||||||
"accountImport": {
|
"accountImport": {
|
||||||
@@ -104,6 +148,7 @@
|
|||||||
"language": "语言",
|
"language": "语言",
|
||||||
"apiHealth": "API健康",
|
"apiHealth": "API健康",
|
||||||
"proxy": "代理",
|
"proxy": "代理",
|
||||||
|
"notifications": "消息推送",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
"logoutConfirm": "确认退出",
|
"logoutConfirm": "确认退出",
|
||||||
"logoutConfirmDesc": "确定要退出登录吗?",
|
"logoutConfirmDesc": "确定要退出登录吗?",
|
||||||
@@ -330,5 +375,75 @@
|
|||||||
"deleteSuccess": "删除跟单成功",
|
"deleteSuccess": "删除跟单成功",
|
||||||
"deleteFailed": "删除跟单失败",
|
"deleteFailed": "删除跟单失败",
|
||||||
"deleteConfirm": "确定要删除这个跟单关系吗?"
|
"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": "通过 <strong>@BotFather</strong> 创建 Telegram 机器人,获取 Bot Token",
|
||||||
|
"step2": "填写 Bot Token 后,点击\"获取 Chat ID\"按钮自动获取(需要先向机器人发送消息)",
|
||||||
|
"step3": "或通过 <strong>@userinfobot</strong> 手动获取 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@
|
|||||||
"language": "語言",
|
"language": "語言",
|
||||||
"apiHealth": "API健康",
|
"apiHealth": "API健康",
|
||||||
"proxy": "代理",
|
"proxy": "代理",
|
||||||
|
"notifications": "消息推送",
|
||||||
"logout": "退出登錄",
|
"logout": "退出登錄",
|
||||||
"logoutConfirm": "確認退出",
|
"logoutConfirm": "確認退出",
|
||||||
"logoutConfirmDesc": "確定要退出登錄嗎?",
|
"logoutConfirmDesc": "確定要退出登錄嗎?",
|
||||||
@@ -428,5 +429,75 @@
|
|||||||
"deleteSuccess": "刪除跟單成功",
|
"deleteSuccess": "刪除跟單成功",
|
||||||
"deleteFailed": "刪除跟單失敗",
|
"deleteFailed": "刪除跟單失敗",
|
||||||
"deleteConfirm": "確定要刪除這個跟單關係嗎?"
|
"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": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
||||||
|
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||||
|
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<NotificationConfig[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(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 <TelegramConfigForm form={form} />
|
||||||
|
case 'discord':
|
||||||
|
return <DiscordConfigForm form={form} />
|
||||||
|
case 'slack':
|
||||||
|
return <SlackConfigForm form={form} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.configName'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.type'),
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('notificationSettings.status'),
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
key: 'enabled',
|
||||||
|
render: (enabled: boolean) => (
|
||||||
|
<Tag color={enabled ? 'green' : 'default'}>
|
||||||
|
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{chatIds.join(', ')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="danger" style={{ fontSize: '12px' }}>{t('notificationSettings.chatIdsNotConfigured')}</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('common.actions'),
|
||||||
|
key: 'action',
|
||||||
|
width: isMobile ? 120 : 200,
|
||||||
|
render: (_: any, record: NotificationConfig) => (
|
||||||
|
<Space size="small" wrap>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.edit')}
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={record.enabled}
|
||||||
|
size="small"
|
||||||
|
onChange={(checked) => handleUpdateEnabled(record.id!, checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
loading={testLoading}
|
||||||
|
onClick={handleTest}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.test')}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t('notificationSettings.deleteConfirm')}
|
||||||
|
onConfirm={() => handleDelete(record.id!)}
|
||||||
|
okText={t('common.confirm')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? '16px' : '24px' }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>{t('notificationSettings.title')}</Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{t('notificationSettings.addConfig')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={configs}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: isMobile ? 600 : 'auto' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
width={isMobile ? '90%' : 600}
|
||||||
|
okText={t('common.confirm')}
|
||||||
|
cancelText={t('common.cancel')}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="type"
|
||||||
|
label={t('notificationSettings.type')}
|
||||||
|
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
|
||||||
|
>
|
||||||
|
<Input disabled value="telegram" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('notificationSettings.configName')}
|
||||||
|
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="enabled"
|
||||||
|
label={t('notificationSettings.enabled')}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 根据推送类型动态渲染配置表单组件 */}
|
||||||
|
<Form.Item shouldUpdate={(prevValues, currentValues) => {
|
||||||
|
// 监听 type 变化,以及编辑时 config 数据的变化
|
||||||
|
return prevValues.type !== currentValues.type ||
|
||||||
|
prevValues.config !== currentValues.config
|
||||||
|
}}>
|
||||||
|
{() => {
|
||||||
|
const currentType = form.getFieldValue('type') || 'telegram'
|
||||||
|
return getConfigFormComponent(currentType)
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationSettings
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosInstance, AxiosError } from 'axios'
|
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 { getToken, setToken, removeToken } from '../utils'
|
||||||
import { wsManager } from './websocket'
|
import { wsManager } from './websocket'
|
||||||
import i18n from '../i18n/config'
|
import i18n from '../i18n/config'
|
||||||
@@ -474,6 +474,59 @@ export const apiService = {
|
|||||||
responseTime?: number
|
responseTime?: number
|
||||||
}>
|
}>
|
||||||
}>>('/proxy-config/api-health-check', {})
|
}>>('/proxy-config/api-health-check', {})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息推送配置 API
|
||||||
|
*/
|
||||||
|
notifications: {
|
||||||
|
/**
|
||||||
|
* 获取配置列表
|
||||||
|
*/
|
||||||
|
list: (data?: { type?: string }) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationConfig[]>>('/notifications/configs/list', data || {}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置详情
|
||||||
|
*/
|
||||||
|
detail: (data: { id: number }) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/detail', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建配置
|
||||||
|
*/
|
||||||
|
create: (data: NotificationConfigRequest) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/create', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
*/
|
||||||
|
update: (data: NotificationConfigUpdateRequest) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/update', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新启用状态
|
||||||
|
*/
|
||||||
|
updateEnabled: (data: { id: number; enabled: boolean }) =>
|
||||||
|
apiClient.post<ApiResponse<NotificationConfig>>('/notifications/configs/update-enabled', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除配置
|
||||||
|
*/
|
||||||
|
delete: (data: { id: number }) =>
|
||||||
|
apiClient.post<ApiResponse<void>>('/notifications/configs/delete', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通知
|
||||||
|
*/
|
||||||
|
test: (data?: { message?: string }) =>
|
||||||
|
apiClient.post<ApiResponse<boolean>>('/notifications/test', data || {}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Telegram Chat IDs
|
||||||
|
*/
|
||||||
|
getTelegramChatIds: (data: { botToken: string }) =>
|
||||||
|
apiClient.post<ApiResponse<string[]>>('/notifications/telegram/get-chat-ids', data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -600,3 +600,49 @@ export interface OrderTrackingRequest {
|
|||||||
buyOrderId?: string
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user