feat: 完善 Telegram 推送通知功能

- 推送模板优化:
  - 优先使用账户名称而不是钱包地址
  - 使用市场标题而不是16进制ID
  - 添加可点击的市场链接(支持 slug 和 conditionId)
  - 添加市场方向(outcome)显示
  - 数量和价格从订单详情API获取实际值
  - 失败通知只显示后端返回的msg,不显示完整堆栈

- 多语言支持:
  - 后端推送消息支持多语言(使用前端最后请求的语言)
  - 添加所有 ErrorCode 的多语言资源文件(中文简体、繁体、英文)
  - 通知消息文本全部使用多语言资源

- 功能改进:
  - 从订单详情获取实际的 side、price、size、outcome
  - 支持买入/卖出方向的多语言显示
  - 错误信息优化,只显示后端返回的错误消息
This commit is contained in:
WrBug
2025-12-05 02:20:46 +08:00
parent 41596887c9
commit 777710c2ed
32 changed files with 4059 additions and 21 deletions
@@ -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_ALREADY_REDEEMED(4504, "仓位已赎回", "error.position_already_redeemed"),
// 通知配置相关 (4601-4699)
NOTIFICATION_CONFIG_NOT_FOUND(4601, "通知配置不存在", "error.notification_config_not_found"),
NOTIFICATION_CONFIG_ID_EMPTY(4602, "配置ID不能为空", "error.notification_config_id_empty"),
NOTIFICATION_CONFIG_TYPE_EMPTY(4603, "推送类型不能为空", "error.notification_config_type_empty"),
NOTIFICATION_CONFIG_NAME_EMPTY(4604, "配置名称不能为空", "error.notification_config_name_empty"),
NOTIFICATION_CONFIG_DATA_EMPTY(4605, "配置信息不能为空", "error.notification_config_data_empty"),
NOTIFICATION_CONFIG_BOT_TOKEN_EMPTY(4606, "Bot Token 不能为空", "error.notification_config_bot_token_empty"),
NOTIFICATION_CONFIG_CREATE_FAILED(4607, "创建配置失败", "error.notification_config_create_failed"),
NOTIFICATION_CONFIG_UPDATE_FAILED(4608, "更新配置失败", "error.notification_config_update_failed"),
NOTIFICATION_CONFIG_DELETE_FAILED(4609, "删除配置失败", "error.notification_config_delete_failed"),
NOTIFICATION_CONFIG_UPDATE_ENABLED_FAILED(4610, "更新启用状态失败", "error.notification_config_update_enabled_failed"),
NOTIFICATION_CONFIG_FETCH_FAILED(4611, "获取配置失败", "error.notification_config_fetch_failed"),
NOTIFICATION_TEST_FAILED(4612, "发送测试消息失败,请检查配置", "error.notification_test_failed"),
NOTIFICATION_GET_CHAT_IDS_FAILED(4613, "获取 Chat IDs 失败", "error.notification_get_chat_ids_failed"),
// 账户相关业务错误 (4601-4699)
ACCOUNT_ALREADY_EXISTS(4601, "账户已存在", "error.account_already_exists"),
ACCOUNT_IS_DEFAULT(4602, "账户已是默认账户", "error.account_is_default"),
ACCOUNT_HAS_ACTIVE_ORDERS(4603, "账户有活跃订单", "error.account_has_active_orders"),
ACCOUNT_IS_LAST_ONE(4604, "不能删除最后一个账户", "error.account_is_last_one"),
ACCOUNT_API_KEY_CREATE_FAILED(4605, "自动获取 API Key 失败", "error.account_api_key_create_failed"),
ACCOUNT_PROXY_ADDRESS_FETCH_FAILED(4606, "获取代理地址失败", "error.account_proxy_address_fetch_failed"),
ACCOUNT_BALANCE_FETCH_FAILED(4607, "查询账户余额失败", "error.account_balance_fetch_failed"),
ACCOUNT_POSITIONS_FETCH_FAILED(4608, "查询仓位列表失败", "error.account_positions_fetch_failed"),
ACCOUNT_IS_DEFAULT(4702, "账户已是默认账户", "error.account_is_default"),
ACCOUNT_HAS_ACTIVE_ORDERS(4703, "账户有活跃订单", "error.account_has_active_orders"),
ACCOUNT_IS_LAST_ONE(4704, "不能删除最后一个账户", "error.account_is_last_one"),
ACCOUNT_API_KEY_CREATE_FAILED(4705, "自动获取 API Key 失败", "error.account_api_key_create_failed"),
ACCOUNT_PROXY_ADDRESS_FETCH_FAILED(4706, "获取代理地址失败", "error.account_proxy_address_fetch_failed"),
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
// 统计相关 (4701-4799)
STATISTICS_FETCH_FAILED(4701, "获取统计信息失败", "error.statistics_fetch_failed"),
ORDER_LIST_FETCH_FAILED(4702, "查询订单列表失败", "error.order_list_fetch_failed"),
// 统计相关 (4801-4899)
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
// ==================== 服务器内部错误 (5001-5999) ====================
SERVER_ERROR(5001, "服务器内部错误", "error.server.error"),
@@ -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.eq
import com.wrbug.polymarketbot.util.JsonUtils
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -27,10 +27,14 @@ class AccountService(
private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 市价单价格调整系数(在最优价基础上调整,确保更快成交)
// 市价买单:bestAsk + BUY_PRICE_ADJUSTMENT(加价,确保能立即成交)
@@ -859,9 +863,60 @@ class AccountService(
if (orderResponse.isSuccessful && orderResponse.body() != null) {
val response = orderResponse.body()!!
if (response.success) {
val orderId = response.orderId ?: ""
// 发送订单成功通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
side = request.side,
accountName = account.accountName,
walletAddress = account.walletAddress,
clobApi = clobApi,
apiKey = account.apiKey,
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
walletAddressForApi = account.walletAddress,
locale = locale
)
} catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e)
}
}
Result.success(
PositionSellResponse(
orderId = response.orderId ?: "",
orderId = orderId,
marketId = request.marketId,
side = request.side,
orderType = request.orderType,
@@ -875,6 +930,54 @@ class AccountService(
val errorMsg = response.errorMsg ?: "未知错误"
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, errorMsg=$errorMsg"
logger.error(fullErrorMsg)
// 发送订单失败通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
side = request.side,
outcome = null, // 失败时可能没有 outcome
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
size = sellQuantity.toString(),
errorMessage = errorMsg, // 只传递后端返回的 msg
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
)
} catch (e: Exception) {
logger.warn("发送订单失败通知失败: ${e.message}", e)
}
}
Result.failure(Exception(fullErrorMsg))
}
} else {
@@ -885,6 +988,57 @@ class AccountService(
}
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
logger.error(fullErrorMsg)
// 发送订单失败通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 只传递后端返回的 msg,不传递完整堆栈
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
side = request.side,
outcome = null, // 失败时可能没有 outcome
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
size = sellQuantity.toString(),
errorMessage = errorMsg, // 只传递后端返回的 msg
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
)
} catch (e: Exception) {
logger.warn("发送订单失败通知失败: ${e.message}", e)
}
}
Result.failure(Exception(fullErrorMsg))
}
} catch (e: Exception) {
@@ -7,7 +7,7 @@ import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.repository.*
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
@@ -34,11 +34,15 @@ class CopyOrderTrackingService(
private val orderSigningService: OrderSigningService,
private val blockchainService: BlockchainService,
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* 解密账户私钥
*/
@@ -301,6 +305,54 @@ class CopyOrderTrackingService(
errorMessage = errorMsg,
retryCount = 1 // 已重试一次
)
// 发送订单失败通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketSlug = marketSlug,
side = "BUY",
outcome = null, // 失败时可能没有 outcome
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
errorMessage = errorMsg, // 只传递后端返回的 msg
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
)
} catch (e: Exception) {
logger.warn("发送订单失败通知失败: ${e.message}", e)
}
}
continue
}
@@ -324,6 +376,80 @@ class CopyOrderTrackingService(
)
copyOrderTrackingRepository.save(tracking)
// 发送订单成功通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
// 重新创建 CLOB API 客户端用于查询订单详情
val apiSecret = try {
decryptApiSecret(account)
} catch (e: Exception) {
logger.warn("解密 API Secret 失败: ${e.message}", e)
null
}
val apiPassphrase = try {
decryptApiPassphrase(account)
} catch (e: Exception) {
logger.warn("解密 API Passphrase 失败: ${e.message}", e)
null
}
val clobApiForQuery = if (account.apiKey != null && apiSecret != null && apiPassphrase != null) {
retrofitFactory.createClobApi(
account.apiKey!!,
apiSecret,
apiPassphrase,
account.walletAddress
)
} else {
null
}
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderSuccessNotification(
orderId = realOrderId,
marketTitle = marketTitle,
marketId = trade.market,
marketSlug = marketSlug,
side = "BUY",
accountName = account.accountName,
walletAddress = account.walletAddress,
clobApi = clobApiForQuery,
apiKey = account.apiKey,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase,
walletAddressForApi = account.walletAddress,
locale = locale
)
} catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
// 继续处理下一个跟单关系
@@ -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
)
}
}
@@ -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 市场IDconditionId用于生成链接
* @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 × sizeUSDC
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, // 市场IDconditionId),用于生成链接
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 × sizeUSDC
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("<", "&lt;").replace(">", "&gt;")
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
// 格式化金额显示
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("<", "&lt;").replace(">", "&gt;")
"\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("<", "&lt;").replace(">", "&gt;")
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
val escapedErrorMessage = shortErrorMessage.replace("<", "&lt;").replace(">", "&gt;")
// 格式化金额显示
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("<", "&lt;").replace(">", "&gt;")
"\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=訂單匹配失敗