From cb167d442f5f71c18f452d94f62af80647a037b1 Mon Sep 17 00:00:00 2001 From: WrBug Date: Sun, 7 Dec 2025 02:06:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E4=BB=93=E4=BD=8D?= =?UTF-8?q?=E8=BD=AE=E8=AE=AD=E9=80=BB=E8=BE=91=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改动: 1. 创建独立的 PositionCheckService 服务 - 将仓位检查逻辑从 PositionPushService 中分离 - 实现待赎回仓位检查和未卖出订单检查 - 实现订单状态更新逻辑(FIFO策略) 2. 修改 PositionPushService - 后端启动时自动启动轮训任务 - 每次轮训后推送全量数据给所有订阅的客户端 - 调用 PositionCheckService 进行仓位检查 3. 自动赎回功能调整 - 将自动赎回从跟单配置级别移到系统级别 - 添加系统级自动赎回配置管理 - 支持 Builder API Key 配置检查 4. 多语言配置修复 - 修复 PositionCheckService 中的硬编码消息 - 使用 MessageSource 获取多语言文本 - 添加自动赎回和 Builder API Key 相关的多语言资源 5. 数据库迁移 - V8: 添加跟单配置名称字段 - V9: 将自动赎回配置移到系统配置表 6. 前端更新 - 添加系统级自动赎回配置界面 - 更新跟单配置管理界面 - 添加 Builder API Key 配置界面 --- .../controller/SystemConfigController.kt | 61 ++ .../com/wrbug/polymarketbot/dto/AccountDto.kt | 6 +- .../wrbug/polymarketbot/dto/CopyTradingDto.kt | 13 +- .../polymarketbot/dto/OrderMessageDto.kt | 5 +- .../wrbug/polymarketbot/entity/CopyTrading.kt | 7 + .../repository/CopyOrderTrackingRepository.kt | 5 + .../polymarketbot/service/AccountService.kt | 33 +- .../service/CopyOrderTrackingService.kt | 85 +-- .../service/CopyTradingService.kt | 29 +- .../polymarketbot/service/OrderPushService.kt | 34 +- .../service/PositionCheckService.kt | 589 ++++++++++++++++++ .../service/PositionPushService.kt | 62 +- .../service/SystemConfigService.kt | 42 +- .../service/TelegramNotificationService.kt | 158 ++++- .../src/main/resources/application.properties | 4 +- .../V8__add_copy_trading_config_fields.sql | 19 + .../V9__move_auto_redeem_to_system_config.sql | 30 + .../resources/i18n/messages_en.properties | 26 + .../resources/i18n/messages_zh_CN.properties | 26 + .../resources/i18n/messages_zh_TW.properties | 26 + frontend/src/App.tsx | 18 +- frontend/src/locales/en/common.json | 37 ++ frontend/src/locales/zh-CN/common.json | 39 ++ frontend/src/locales/zh-TW/common.json | 37 ++ frontend/src/pages/BuilderApiKeySettings.tsx | 29 +- frontend/src/pages/CopyTradingAdd.tsx | 57 +- frontend/src/pages/CopyTradingEdit.tsx | 37 +- frontend/src/pages/CopyTradingList.tsx | 19 +- frontend/src/pages/SystemSettings.tsx | 99 ++- frontend/src/services/api.ts | 20 +- frontend/src/types/index.ts | 13 + 31 files changed, 1544 insertions(+), 121 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt create mode 100644 backend/src/main/resources/db/migration/V8__add_copy_trading_config_fields.sql create mode 100644 backend/src/main/resources/db/migration/V9__move_auto_redeem_to_system_config.sql diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt index 2bca386..1b8d83c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt @@ -65,5 +65,66 @@ class SystemConfigController( } } + /** + * 检查 Builder API Key 是否已配置 + */ + @PostMapping("/builder-api-key/check") + fun checkBuilderApiKey(): ResponseEntity>> { + return try { + val isConfigured = relayClientService.isBuilderApiKeyConfigured() + val result = mapOf("configured" to isConfigured) + ResponseEntity.ok(ApiResponse.success(result)) + } catch (e: Exception) { + logger.error("检查 Builder API Key 配置失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查 Builder API Key 配置失败: ${e.message}", messageSource)) + } + } + + /** + * 更新自动赎回配置 + */ + @PostMapping("/auto-redeem/update") + fun updateAutoRedeem(@RequestBody request: Map): ResponseEntity> { + return try { + val enabled = request["enabled"] ?: return ResponseEntity.ok( + ApiResponse.error(ErrorCode.PARAM_ERROR, "参数错误:缺少 enabled 字段", messageSource) + ) + val result = systemConfigService.updateAutoRedeem(enabled) + result.fold( + onSuccess = { config -> + ResponseEntity.ok(ApiResponse.success(config)) + }, + onFailure = { e -> + logger.error("更新自动赎回配置失败: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ERROR, + "更新自动赎回配置失败: ${e.message}", + messageSource + ) + ) + } + ) + } catch (e: Exception) { + logger.error("更新自动赎回配置异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "更新自动赎回配置失败: ${e.message}", messageSource)) + } + } + + /** + * 获取自动赎回状态 + */ + @PostMapping("/auto-redeem/status") + fun getAutoRedeemStatus(): ResponseEntity>> { + return try { + val enabled = systemConfigService.isAutoRedeemEnabled() + val result = mapOf("enabled" to enabled) + ResponseEntity.ok(ApiResponse.success(result)) + } catch (e: Exception) { + logger.error("获取自动赎回状态失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取自动赎回状态失败: ${e.message}", messageSource)) + } + } + } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index d691a8d..a7cb219 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -25,7 +25,8 @@ data class AccountUpdateRequest( data class SystemConfigUpdateRequest( val builderApiKey: String? = null, // Builder API Key(前端加密后传输) val builderSecret: String? = null, // Builder Secret(前端加密后传输) - val builderPassphrase: String? = null // Builder Passphrase(前端加密后传输) + val builderPassphrase: String? = null, // Builder Passphrase(前端加密后传输) + val autoRedeem: Boolean? = null // 自动赎回(系统级别配置) ) /** @@ -34,7 +35,8 @@ data class SystemConfigUpdateRequest( data class SystemConfigDto( val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置 val builderSecretConfigured: Boolean, // Builder Secret 是否已配置 - val builderPassphraseConfigured: Boolean // Builder Passphrase 是否已配置 + val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置 + val autoRedeem: Boolean = true // 自动赎回(系统级别配置,默认开启) ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt index 483eed1..7a7bf6c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt @@ -34,7 +34,10 @@ data class CopyTradingCreateRequest( val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 - val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 + val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + val configName: String? = null, // 配置名(可选) + val pushFailedOrders: Boolean? = null // 推送失败订单(可选) ) /** @@ -63,7 +66,10 @@ data class CopyTradingUpdateRequest( val maxSpread: String? = null, val minOrderbookDepth: String? = null, val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 - val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 + val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + val configName: String? = null, // 配置名(可选,但提供时必须非空) + val pushFailedOrders: Boolean? = null // 推送失败订单(可选) ) /** @@ -130,6 +136,9 @@ data class CopyTradingDto( val minOrderbookDepth: String?, val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + val configName: String? = null, // 配置名(可选) + val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭) val createdAt: Long, val updatedAt: Long ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt index 5d6d869..96a33fd 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt @@ -43,7 +43,10 @@ data class OrderPushMessage( val accountName: String, // 账户名称 val order: OrderMessageDto, // 订单信息(来自 WebSocket) val orderDetail: OrderDetailDto? = null, // 订单详情(通过 API 获取) - val timestamp: Long = System.currentTimeMillis() // 推送时间戳 + val timestamp: Long = System.currentTimeMillis(), // 推送时间戳 + // 跟单相关字段(可选,仅在跟单触发的订单时提供) + val leaderName: String? = null, // Leader 名称(备注) + val configName: String? = null // 跟单配置名 ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt index 2acdbc0..3b82768 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt @@ -87,6 +87,13 @@ data class CopyTrading( @Column(name = "max_price", precision = 20, scale = 8) val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + @Column(name = "config_name", length = 255) + val configName: String? = null, // 配置名(可选) + + @Column(name = "push_failed_orders", nullable = false) + val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭) + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt index 6158959..7b6ef07 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyOrderTrackingRepository.kt @@ -45,5 +45,10 @@ interface CopyOrderTrackingRepository : JpaRepository { * 根据Leader交易ID查询订单 */ fun findByLeaderBuyTradeId(leaderBuyTradeId: String): CopyOrderTracking? + + /** + * 根据买入订单ID查询订单跟踪记录 + */ + fun findByBuyOrderId(buyOrderId: String): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index 3731825..1e29329 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -1351,7 +1351,38 @@ class AccountService( ) } - // 6. 返回结果 + // 6. 发送赎回推送通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取当前语言设置 + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + // 为每个账户发送推送 + for (transaction in accountTransactions) { + val account = accounts[transaction.accountId] + if (account != null) { + telegramNotificationService?.sendRedeemNotification( + accountName = account.accountName, + walletAddress = account.walletAddress, + transactionHash = transaction.transactionHash, + totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info -> + sum.add(info.value.toSafeBigDecimal()) + }.toPlainString(), + positions = transaction.positions, + locale = locale + ) + } + } + } catch (e: Exception) { + logger.error("发送赎回推送通知失败: ${e.message}", e) + } + } + + // 7. 返回结果 Result.success( com.wrbug.polymarketbot.dto.PositionRedeemResponse( transactions = accountTransactions, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt index 18bf107..b577101 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt @@ -408,50 +408,52 @@ class CopyOrderTrackingService( 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 { + // 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送) + if (copyTrading.pushFailedOrders) { + 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 } - } catch (e: Exception) { - logger.warn("获取市场信息失败: ${e.message}", e) - null } - } - val marketTitle = marketInfo?.question ?: trade.market - val marketSlug = marketInfo?.slug + val marketTitle = marketInfo?.question ?: trade.market + val marketSlug = marketInfo?.slug - // 获取当前语言设置(从 LocaleContextHolder) - val locale = try { - org.springframework.context.i18n.LocaleContextHolder.getLocale() + // 获取当前语言设置(从 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 = exception?.message.orEmpty(), // 只传递后端返回的 msg + accountName = account.accountName, + walletAddress = account.walletAddress, + locale = locale + ) } catch (e: Exception) { - java.util.Locale("zh", "CN") // 默认简体中文 + logger.warn("发送订单失败通知失败: ${e.message}", e) } - - telegramNotificationService?.sendOrderFailureNotification( - marketTitle = marketTitle, - marketId = trade.market, - marketSlug = marketSlug, - side = "BUY", - outcome = null, // 失败时可能没有 outcome - price = buyPrice.toString(), - size = finalBuyQuantity.toString(), - errorMessage = exception?.message.orEmpty(), // 只传递后端返回的 msg - accountName = account.accountName, - walletAddress = account.walletAddress, - locale = locale - ) - } catch (e: Exception) { - logger.warn("发送订单失败通知失败: ${e.message}", e) } } @@ -531,6 +533,11 @@ class CopyOrderTrackingService( } catch (e: Exception) { java.util.Locale("zh", "CN") // 默认简体中文 } + + // 获取 Leader 和跟单配置信息 + val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) + val leaderName = leader?.leaderName + val configName = copyTrading.configName telegramNotificationService?.sendOrderSuccessNotification( orderId = realOrderId, @@ -545,7 +552,9 @@ class CopyOrderTrackingService( apiSecret = apiSecret, apiPassphrase = apiPassphrase, walletAddressForApi = account.walletAddress, - locale = locale + locale = locale, + leaderName = leaderName, + configName = configName ) } catch (e: Exception) { logger.warn("发送订单成功通知失败: ${e.message}", e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt index 0c38bac..2ad2262 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt @@ -52,7 +52,13 @@ class CopyTradingService( return Result.failure(IllegalArgumentException("该跟单关系已存在")) } - // 4. 获取配置参数(从模板填充或手动输入) + // 4. 验证配置名(强校验:不能为空字符串) + val configName = request.configName?.trim() + if (configName.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("配置名不能为空")) + } + + // 5. 获取配置参数(从模板填充或手动输入) val config = if (request.templateId != null) { // 从模板填充 val template = templateRepository.findById(request.templateId).orElse(null) @@ -109,7 +115,7 @@ class CopyTradingService( ) } - // 5. 创建跟单配置 + // 6. 创建跟单配置 val copyTrading = CopyTrading( accountId = request.accountId, leaderId = request.leaderId, @@ -132,7 +138,9 @@ class CopyTradingService( maxSpread = config.maxSpread, minOrderbookDepth = config.minOrderbookDepth, minPrice = config.minPrice, - maxPrice = config.maxPrice + maxPrice = config.maxPrice, + configName = configName, + pushFailedOrders = request.pushFailedOrders ?: false ) val saved = copyTradingRepository.save(copyTrading) @@ -164,6 +172,17 @@ class CopyTradingService( val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null) ?: return Result.failure(IllegalArgumentException("跟单配置不存在")) + // 验证配置名(如果提供了配置名,进行强校验) + val configName = if (request.configName != null) { + val trimmed = request.configName.trim() + if (trimmed.isBlank()) { + return Result.failure(IllegalArgumentException("配置名不能为空")) + } + trimmed + } else { + copyTrading.configName + } + // 更新字段(只更新提供的字段) val updated = copyTrading.copy( enabled = request.enabled ?: copyTrading.enabled, @@ -186,6 +205,8 @@ class CopyTradingService( minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth, minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice, + configName = configName, + pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders, updatedAt = System.currentTimeMillis() ) @@ -390,6 +411,8 @@ class CopyTradingService( minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(), minPrice = copyTrading.minPrice?.toPlainString(), maxPrice = copyTrading.maxPrice?.toPlainString(), + configName = copyTrading.configName, + pushFailedOrders = copyTrading.pushFailedOrders, createdAt = copyTrading.createdAt, updatedAt = copyTrading.updatedAt ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt index 138680e..e77f202 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt @@ -26,7 +26,10 @@ class OrderPushService( private val objectMapper: ObjectMapper, private val clobService: PolymarketClobService, private val retrofitFactory: RetrofitFactory, // 用于创建 Gamma API 客户端(不需要认证) - private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils + private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, + private val copyOrderTrackingRepository: com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository? = null, // 可选,避免循环依赖 + private val copyTradingRepository: com.wrbug.polymarketbot.repository.CopyTradingRepository? = null, // 可选,避免循环依赖 + private val leaderRepository: com.wrbug.polymarketbot.repository.LeaderRepository? = null // 可选,避免循环依赖 ) { private val logger = LoggerFactory.getLogger(OrderPushService::class.java) @@ -316,15 +319,40 @@ class OrderPushService( if (eventType == "order") { val orderMessage = objectMapper.readValue(message, OrderMessageDto::class.java) - // 异步获取订单详情 + // 异步获取订单详情和跟单信息 scope.launch { val orderDetail = fetchOrderDetail(account, orderMessage.id, orderMessage.market) + + // 查询订单是否来自跟单 + var leaderName: String? = null + var configName: String? = null + + if (copyOrderTrackingRepository != null && copyTradingRepository != null && leaderRepository != null) { + try { + val trackingList = copyOrderTrackingRepository.findByBuyOrderId(orderMessage.id) + val tracking = trackingList.firstOrNull() + if (tracking != null) { + val copyTrading = copyTradingRepository.findById(tracking.copyTradingId).orElse(null) + if (copyTrading != null) { + configName = copyTrading.configName + val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null) + if (leader != null) { + leaderName = leader.leaderName + } + } + } + } catch (e: Exception) { + logger.warn("查询跟单信息失败: orderId=${orderMessage.id}, ${e.message}", e) + } + } val pushMessage = OrderPushMessage( accountId = account.id!!, accountName = account.accountName ?: account.walletAddress, order = orderMessage, - orderDetail = orderDetail + orderDetail = orderDetail, + leaderName = leaderName, + configName = configName ) // 推送给所有订阅者 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt new file mode 100644 index 0000000..4fdf549 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt @@ -0,0 +1,589 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.AccountPositionDto +import com.wrbug.polymarketbot.entity.CopyOrderTracking +import com.wrbug.polymarketbot.entity.CopyTrading +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository +import com.wrbug.polymarketbot.repository.CopyTradingRepository +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import jakarta.annotation.PostConstruct +import org.springframework.context.MessageSource +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap + +/** + * 仓位检查服务 + * 负责检查待赎回仓位和未卖出订单,并执行相应的处理逻辑 + */ +@Service +class PositionCheckService( + private val accountService: AccountService, + private val copyTradingRepository: CopyTradingRepository, + private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val systemConfigService: SystemConfigService, + private val relayClientService: RelayClientService, + private val telegramNotificationService: TelegramNotificationService?, + private val accountRepository: AccountRepository, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(PositionCheckService::class.java) + + // 协程作用域,用于缓存清理任务 + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // 记录已发送通知的仓位(避免重复推送) + private val notifiedRedeemablePositions = ConcurrentHashMap() // "accountId_marketId_outcomeIndex" -> lastNotificationTime + + // 记录已发送提示的配置(避免重复推送) + private val notifiedConfigs = ConcurrentHashMap() // accountId/copyTradingId -> lastNotificationTime + + /** + * 初始化服务(启动缓存清理任务) + */ + @PostConstruct + fun init() { + startCacheCleanup() + } + + /** + * 启动缓存清理任务(定期清理过期的通知记录) + */ + private fun startCacheCleanup() { + scope.launch { + while (isActive) { + try { + delay(7200000) // 每2小时清理一次 + cleanupExpiredCache() + } catch (e: Exception) { + logger.error("清理缓存异常: ${e.message}", e) + } + } + } + } + + /** + * 清理过期的缓存条目(超过2小时的记录) + */ + private fun cleanupExpiredCache() { + val now = System.currentTimeMillis() + val expireTime = 7200000 // 2小时 + + // 清理过期的仓位通知记录 + val expiredPositions = notifiedRedeemablePositions.entries.filter { (_, timestamp) -> + (now - timestamp) > expireTime + } + expiredPositions.forEach { (key, _) -> + notifiedRedeemablePositions.remove(key) + } + + // 清理过期的配置通知记录 + val expiredConfigs = notifiedConfigs.entries.filter { (_, timestamp) -> + (now - timestamp) > expireTime + } + expiredConfigs.forEach { (key, _) -> + notifiedConfigs.remove(key) + } + + if (expiredPositions.isNotEmpty() || expiredConfigs.isNotEmpty()) { + logger.debug("清理过期缓存: positions=${expiredPositions.size}, configs=${expiredConfigs.size}") + } + } + + /** + * 检查仓位(主入口) + * 根据 positionloop.md 文档要求: + * 1. 处理待赎回仓位 + * 2. 处理未卖出订单 + */ + suspend fun checkPositions(currentPositions: List) { + try { + // 逻辑1:处理待赎回仓位 + val redeemablePositions = currentPositions.filter { it.redeemable } + if (redeemablePositions.isNotEmpty()) { + checkRedeemablePositions(redeemablePositions) + } + + // 逻辑2:处理未卖出订单(如果没有待赎回仓位或已处理完) + checkUnmatchedOrders(currentPositions) + } catch (e: Exception) { + logger.error("仓位检查异常: ${e.message}", e) + } + } + + /** + * 逻辑1:处理待赎回仓位 + * 如果有待赎回的仓位,检查是否开启了自动赎回,是否有相同仓位的订单(未卖出的订单) + * 如果有的话,在仓位赎回成功后以该订单卖出逻辑更新所有订单状态(未卖出) + * 如果未开启自动赎回,则发送tg通知,并且记录(内存缓存)该仓位,避免重复发送 + */ + private suspend fun checkRedeemablePositions(redeemablePositions: List) { + try { + // 检查系统级别的自动赎回配置 + val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled() + + // 按账户分组 + val positionsByAccount = redeemablePositions.groupBy { it.accountId } + + for ((accountId, positions) in positionsByAccount) { + // 查找该账户下所有启用的跟单配置 + val copyTradings = copyTradingRepository.findByAccountId(accountId) + .filter { it.enabled } + + if (copyTradings.isEmpty()) { + continue + } + + // 收集所有有未卖出订单的仓位,按账户分组一次性处理 + val positionsWithOrders = mutableListOf>>() + val positionsWithoutOrders = mutableListOf() + + for (position in positions) { + // 查找相同仓位的未卖出订单(remaining_quantity > 0) + val unmatchedOrders = mutableListOf() + for (copyTrading in copyTradings) { + if (position.outcomeIndex != null) { + val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex( + copyTrading.id!!, + position.marketId, + position.outcomeIndex + ) + unmatchedOrders.addAll(orders) + } + } + + if (unmatchedOrders.isNotEmpty()) { + positionsWithOrders.add(Pair(position, unmatchedOrders)) + } else { + positionsWithoutOrders.add(position) + } + } + + // 处理有未卖出订单的仓位 + if (positionsWithOrders.isNotEmpty()) { + if (autoRedeemEnabled && relayClientService.isBuilderApiKeyConfigured()) { + // 开启自动赎回且已配置 API Key,执行自动赎回(一次性赎回该账户的所有可赎回仓位) + val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest( + positions = positionsWithOrders.map { (position, _) -> + com.wrbug.polymarketbot.dto.AccountRedeemPositionItem( + accountId = accountId, + marketId = position.marketId, + outcomeIndex = position.outcomeIndex ?: 0, + side = position.side + ) + } + ) + + val redeemResult = accountService.redeemPositions(redeemRequest) + redeemResult.fold( + onSuccess = { response -> + logger.info("自动赎回成功: accountId=$accountId, redeemedCount=${positionsWithOrders.size}, totalValue=${response.totalRedeemedValue}") + // 在仓位赎回成功后,以该订单卖出逻辑更新所有订单状态(未卖出) + for ((position, orders) in positionsWithOrders) { + updateOrdersAsSoldAfterRedeem(orders, position) + } + }, + onFailure = { e -> + logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e) + } + ) + } else { + // 未开启自动赎回或未配置 API Key,发送通知 + for ((position, _) in positionsWithOrders) { + val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}" + if (!autoRedeemEnabled) { + checkAndNotifyAutoRedeemDisabled(accountId, listOf(position)) + } else { + // API Key 未配置 + for (copyTrading in copyTradings) { + checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position)) + } + } + // 记录已发送通知的仓位(避免重复发送) + notifiedRedeemablePositions[positionKey] = System.currentTimeMillis() + } + } + } + + // 处理没有未卖出订单的仓位(如果未开启自动赎回,发送通知) + if (positionsWithoutOrders.isNotEmpty() && !autoRedeemEnabled) { + for (position in positionsWithoutOrders) { + val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}" + // 检查是否在最近2小时内已发送过提示(避免频繁推送) + val lastNotification = notifiedRedeemablePositions[positionKey] + val now = System.currentTimeMillis() + if (lastNotification == null || (now - lastNotification) >= 7200000) { // 2小时 + checkAndNotifyAutoRedeemDisabled(accountId, listOf(position)) + notifiedRedeemablePositions[positionKey] = now + } + } + } + } + } catch (e: Exception) { + logger.error("处理待赎回仓位异常: ${e.message}", e) + } + } + + /** + * 逻辑2:处理未卖出订单 + * 检查所有未卖出的订单,匹配仓位 + * 如果仓位不存在,则更新订单状态为已卖出,卖出价为当前最新价 + * 如果发现有仓位,并且仓位数量小于所有未卖出订单数量总和,则按照订单下单顺序更新状态,卖出价价格为最新价 + */ + private suspend fun checkUnmatchedOrders(currentPositions: List) { + try { + // 获取所有启用的跟单配置 + val allCopyTradings = copyTradingRepository.findAll().filter { it.enabled } + + // 按账户和市场分组当前仓位 + val positionsByAccountAndMarket = currentPositions.groupBy { + "${it.accountId}_${it.marketId}_${it.outcomeIndex ?: 0}" + } + + // 遍历所有跟单配置 + for (copyTrading in allCopyTradings) { + // 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0) + val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!) + .filter { it.remainingQuantity > BigDecimal.ZERO } + .sortedBy { it.createdAt } // 按创建时间排序(FIFO) + + if (unmatchedOrders.isEmpty()) { + continue + } + + // 按市场分组订单 + val ordersByMarket = unmatchedOrders.groupBy { + "${it.marketId}_${it.outcomeIndex ?: 0}" + } + + for ((marketKey, orders) in ordersByMarket) { + // 从订单中获取市场信息 + val firstOrder = orders.firstOrNull() ?: continue + val marketId = firstOrder.marketId + val outcomeIndex = firstOrder.outcomeIndex ?: 0 + + // 查找对应的仓位 + val positionKey = "${copyTrading.accountId}_$marketKey" + val position = positionsByAccountAndMarket[positionKey]?.firstOrNull() + + if (position == null) { + // 仓位不存在,更新所有订单状态为已卖出 + val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex) + updateOrdersAsSold(orders, currentPrice) + } else { + // 有仓位,检查仓位数量是否小于所有未卖出订单数量总和 + val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() } + val positionQuantity = position.quantity.toSafeBigDecimal() + + if (positionQuantity < totalUnmatchedQuantity) { + // 仓位数量小于订单数量总和,按订单下单顺序(FIFO)更新状态 + val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex) + updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice) + } + } + } + } + } catch (e: Exception) { + logger.error("处理未卖出订单异常: ${e.message}", e) + } + } + + /** + * 获取当前市场最新价(用于更新订单卖出价) + * 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价) + */ + private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal { + return try { + val priceResult = accountService.getMarketPrice(marketId, outcomeIndex) + val marketPrice = priceResult.getOrNull() + if (marketPrice != null) { + // 优先使用 bestBid(最优买价,用于卖出参考),如果没有则使用 midpoint + val priceStr = marketPrice.bestBid ?: marketPrice.midpoint ?: marketPrice.lastPrice + priceStr?.toSafeBigDecimal() ?: BigDecimal.ZERO + } else { + BigDecimal.ZERO + } + } catch (e: Exception) { + logger.error("获取市场最新价失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e) + BigDecimal.ZERO + } + } + + /** + * 在仓位赎回成功后,更新订单状态为已卖出 + * 使用卖出逻辑更新所有订单状态(未卖出订单的) + */ + private suspend fun updateOrdersAsSoldAfterRedeem( + orders: List, + position: AccountPositionDto + ) { + try { + val currentPrice = getCurrentMarketPrice(position.marketId, position.outcomeIndex ?: 0) + updateOrdersAsSold(orders, currentPrice) + } catch (e: Exception) { + logger.error("更新订单状态为已卖出失败: ${e.message}", e) + } + } + + /** + * 更新订单状态为已卖出(使用当前最新价) + */ + private suspend fun updateOrdersAsSold( + orders: List, + sellPrice: BigDecimal + ) { + try { + for (order in orders) { + // 更新订单状态:将剩余数量标记为已匹配 + order.matchedQuantity = order.matchedQuantity.add(order.remainingQuantity) + order.remainingQuantity = BigDecimal.ZERO + order.status = "fully_matched" + order.updatedAt = System.currentTimeMillis() + copyOrderTrackingRepository.save(order) + + logger.info("更新订单状态为已卖出: orderId=${order.buyOrderId}, marketId=${order.marketId}, sellPrice=$sellPrice") + } + } catch (e: Exception) { + logger.error("更新订单状态为已卖出异常: ${e.message}", e) + } + } + + /** + * 按 FIFO 顺序更新订单状态为已卖出 + * 仓位数量小于订单数量总和时,按订单下单顺序更新 + */ + private suspend fun updateOrdersAsSoldByFIFO( + orders: List, + availableQuantity: BigDecimal, + sellPrice: BigDecimal + ) { + try { + // 订单已经按 createdAt ASC 排序(FIFO) + var remaining = availableQuantity + + for (order in orders) { + if (remaining <= BigDecimal.ZERO) { + break + } + + val orderRemaining = order.remainingQuantity.toSafeBigDecimal() + val toMatch = minOf(orderRemaining, remaining) + + if (toMatch > BigDecimal.ZERO) { + order.matchedQuantity = order.matchedQuantity.add(toMatch) + order.remainingQuantity = order.remainingQuantity.subtract(toMatch) + + // 更新状态 + if (order.remainingQuantity <= BigDecimal.ZERO) { + order.status = "fully_matched" + } else { + order.status = "partially_matched" + } + + order.updatedAt = System.currentTimeMillis() + copyOrderTrackingRepository.save(order) + + remaining = remaining.subtract(toMatch) + + logger.info("按 FIFO 更新订单状态: orderId=${order.buyOrderId}, matched=$toMatch, remaining=${order.remainingQuantity}") + } + } + } catch (e: Exception) { + logger.error("按 FIFO 更新订单状态异常: ${e.message}", e) + } + } + + /** + * 检查并通知自动赎回未开启 + */ + private suspend fun checkAndNotifyAutoRedeemDisabled(accountId: Long, positions: List) { + if (telegramNotificationService == null) { + return + } + + // 检查是否在最近2小时内已发送过提示(避免频繁推送) + val lastNotification = notifiedConfigs[accountId] + val now = System.currentTimeMillis() + if (lastNotification != null && (now - lastNotification) < 7200000) { // 2小时 + return + } + + try { + val account = accountRepository.findById(accountId).orElse(null) + if (account == null) { + return + } + + // 计算可赎回总价值 + val totalValue = positions.fold(BigDecimal.ZERO) { sum, pos -> + sum.add(pos.quantity.toSafeBigDecimal()) + } + + val message = buildAutoRedeemDisabledMessage( + accountName = account.accountName, + walletAddress = account.walletAddress, + totalValue = totalValue.toPlainString(), + positionCount = positions.size + ) + + telegramNotificationService.sendMessage(message) + notifiedConfigs[accountId] = now + } catch (e: Exception) { + logger.error("发送自动赎回未开启提示失败: accountId=$accountId, ${e.message}", e) + } + } + + /** + * 检查并通知 Builder API Key 未配置 + */ + private suspend fun checkAndNotifyBuilderApiKeyNotConfigured( + copyTrading: CopyTrading, + positions: List + ) { + if (telegramNotificationService == null) { + return + } + + // 检查是否在最近2小时内已发送过提示(避免频繁推送) + val copyTradingId = copyTrading.id ?: return + val lastNotification = notifiedConfigs[copyTradingId] + val now = System.currentTimeMillis() + if (lastNotification != null && (now - lastNotification) < 7200000) { // 2小时 + return + } + + try { + val account = accountRepository.findById(copyTrading.accountId).orElse(null) + if (account == null) { + return + } + + // 计算可赎回总价值 + val totalValue = positions.fold(BigDecimal.ZERO) { sum, pos -> + sum.add(pos.quantity.toSafeBigDecimal()) + } + + val message = buildBuilderApiKeyNotConfiguredMessage( + accountName = account.accountName, + walletAddress = account.walletAddress, + configName = copyTrading.configName, + totalValue = totalValue.toPlainString(), + positionCount = positions.size + ) + + telegramNotificationService.sendMessage(message) + notifiedConfigs[copyTradingId] = now + } catch (e: Exception) { + logger.error("发送 Builder API Key 未配置提示失败: copyTradingId=$copyTradingId, ${e.message}", e) + } + } + + /** + * 构建自动赎回未开启消息 + */ + private fun buildAutoRedeemDisabledMessage( + accountName: String?, + walletAddress: String?, + totalValue: String, + positionCount: Int + ): String { + // 获取当前语言设置 + val locale = try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") + } + + val accountInfo = accountName ?: (walletAddress?.let { maskAddress(it) } ?: messageSource.getMessage("common.unknown", null, "未知", locale)) + val totalValueDisplay = try { + val totalValueDecimal = totalValue.toSafeBigDecimal() + val formatted = if (totalValueDecimal.scale() > 4) { + totalValueDecimal.setScale(4, java.math.RoundingMode.DOWN).toPlainString() + } else { + totalValueDecimal.stripTrailingZeros().toPlainString() + } + formatted + } catch (e: Exception) { + totalValue + } + + // 获取多语言文本 + val title = messageSource.getMessage("notification.auto_redeem.disabled.title", null, "自动赎回未开启", locale) + val accountLabel = messageSource.getMessage("notification.auto_redeem.disabled.account", null, "账户", locale) + val positionsLabel = messageSource.getMessage("notification.auto_redeem.disabled.redeemable_positions", null, "可赎回仓位", locale) + val positionsUnit = messageSource.getMessage("notification.auto_redeem.disabled.positions_unit", null, "个", locale) + val totalValueLabel = messageSource.getMessage("notification.auto_redeem.disabled.total_value", null, "总价值", locale) + val message = messageSource.getMessage("notification.auto_redeem.disabled.message", null, "请在系统设置中开启自动赎回功能。", locale) + + return "⚠️ $title\n\n" + + "$accountLabel: $accountInfo\n" + + "$positionsLabel: $positionCount $positionsUnit\n" + + "$totalValueLabel: $totalValueDisplay USDC\n\n" + + message + } + + /** + * 构建 Builder API Key 未配置消息 + */ + private fun buildBuilderApiKeyNotConfiguredMessage( + accountName: String?, + walletAddress: String?, + configName: String?, + totalValue: String, + positionCount: Int + ): String { + // 获取当前语言设置 + val locale = try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") + } + + val accountInfo = accountName ?: (walletAddress?.let { maskAddress(it) } ?: messageSource.getMessage("common.unknown", null, "未知", locale)) + val unknownConfig = messageSource.getMessage("notification.builder_api_key.not_configured.unknown_config", null, "未命名配置", locale) + val configInfo = configName ?: unknownConfig + val totalValueDisplay = try { + val totalValueDecimal = totalValue.toSafeBigDecimal() + val formatted = if (totalValueDecimal.scale() > 4) { + totalValueDecimal.setScale(4, java.math.RoundingMode.DOWN).toPlainString() + } else { + totalValueDecimal.stripTrailingZeros().toPlainString() + } + formatted + } catch (e: Exception) { + totalValue + } + + // 获取多语言文本 + val title = messageSource.getMessage("notification.builder_api_key.not_configured.title", null, "Builder API Key 未配置", locale) + val accountLabel = messageSource.getMessage("notification.builder_api_key.not_configured.account", null, "账户", locale) + val configLabel = messageSource.getMessage("notification.builder_api_key.not_configured.copy_trading_config", null, "跟单配置", locale) + val positionsLabel = messageSource.getMessage("notification.builder_api_key.not_configured.redeemable_positions", null, "可赎回仓位", locale) + val positionsUnit = messageSource.getMessage("notification.builder_api_key.not_configured.positions_unit", null, "个", locale) + val totalValueLabel = messageSource.getMessage("notification.builder_api_key.not_configured.total_value", null, "总价值", locale) + val message = messageSource.getMessage("notification.builder_api_key.not_configured.message", null, "请在系统设置中配置 Builder API Key 以启用自动赎回功能。", locale) + + return "⚠️ $title\n\n" + + "$accountLabel: $accountInfo\n" + + "$configLabel: $configInfo\n" + + "$positionsLabel: $positionCount $positionsUnit\n" + + "$totalValueLabel: $totalValueDisplay USDC\n\n" + + message + } + + /** + * 掩码地址(只显示前6位和后4位) + */ + private fun maskAddress(address: String): String { + if (address.length <= 10) { + return address + } + return "${address.take(6)}...${address.takeLast(4)}" + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt index 091af10..eb4a91f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt @@ -18,7 +18,8 @@ import java.util.concurrent.ConcurrentHashMap */ @Service class PositionPushService( - private val accountService: AccountService + private val accountService: AccountService, + private val positionCheckService: PositionCheckService ) { private val logger = LoggerFactory.getLogger(PositionPushService::class.java) @@ -41,10 +42,12 @@ class PositionPushService( private val lock = Any() /** - * 初始化服务(不自动启动轮询,等待有客户端连接时再启动) + * 初始化服务(后端启动时直接启动轮询) */ @PostConstruct fun init() { + logger.info("PositionPushService 初始化,启动仓位轮询任务") + startPolling() } /** @@ -75,38 +78,27 @@ class PositionPushService( /** * 注册客户端会话(兼容旧接口) - * 如果有第一个客户端连接,启动轮询任务 + * 轮询任务已在后端启动时启动,这里只需要注册回调 */ fun registerSession(sessionId: String, callback: (PositionPushMessage) -> Unit) { logger.info("注册仓位推送客户端会话: $sessionId") synchronized(lock) { - val wasEmpty = clientCallbacks.isEmpty() clientCallbacks[sessionId] = callback - - // 如果是第一个客户端连接,启动轮询任务 - if (wasEmpty && clientCallbacks.isNotEmpty()) { - logger.info("检测到第一个客户端连接,启动轮询任务") - startPolling() - } + // 轮询任务已在后端启动时启动,不需要在这里启动 } } /** * 注销客户端会话(兼容旧接口) - * 如果没有客户端连接了,停止轮询任务 + * 轮询任务持续运行,不因客户端断开而停止 */ fun unregisterSession(sessionId: String) { logger.info("注销仓位推送客户端会话: $sessionId") synchronized(lock) { clientCallbacks.remove(sessionId) - - // 如果没有客户端连接了,停止轮询任务 - if (clientCallbacks.isEmpty()) { - logger.info("没有客户端连接了,停止轮询任务") - stopPolling() - } + // 轮询任务持续运行,不停止 } } @@ -174,33 +166,26 @@ class PositionPushService( } /** - * 轮询仓位数据并推送增量更新 + * 轮询仓位数据并推送全量数据 + * 根据文档要求:每次轮训完成后向订阅者发送全量数据 */ private suspend fun pollAndPush() { - // 双重检查:如果没有客户端连接,跳过轮询(虽然理论上不应该发生,但作为安全措施) - if (clientCallbacks.isEmpty()) { - return - } - try { val result = accountService.getAllPositions() if (result.isSuccess) { val positions = result.getOrNull() if (positions != null) { - // 比较差异 - val incremental = calculateIncremental( - newCurrentPositions = positions.currentPositions, - newHistoryPositions = positions.historyPositions - ) + // 更新快照 + lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() } + lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() } - // 如果有变化,推送增量更新 - if (incremental != null) { + // 向所有订阅者发送全量数据 + if (clientCallbacks.isNotEmpty()) { val message = PositionPushMessage( - type = PositionPushMessageType.INCREMENTAL, + type = PositionPushMessageType.FULL, timestamp = System.currentTimeMillis(), - currentPositions = incremental.currentPositions, - historyPositions = incremental.historyPositions, - removedPositionKeys = incremental.removedKeys + currentPositions = positions.currentPositions, + historyPositions = positions.historyPositions ) // 推送给所有连接的客户端 @@ -208,15 +193,14 @@ class PositionPushService( try { callback(message) } catch (e: Exception) { - logger.error("推送增量更新失败: ${e.message}", e) + logger.error("推送全量数据失败: ${e.message}", e) } } - } - // 更新快照 - lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() } - lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() } + // 仓位检查逻辑(复用仓位轮询) + // 处理待赎回仓位和未卖出订单 + positionCheckService.checkPositions(positions.currentPositions) } } else { logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt index 2bb7fc1..7ab082d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt @@ -24,6 +24,7 @@ class SystemConfigService( const val CONFIG_KEY_BUILDER_API_KEY = "builder.api_key" const val CONFIG_KEY_BUILDER_SECRET = "builder.secret" const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase" + const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem" } /** @@ -33,11 +34,13 @@ class SystemConfigService( val builderApiKey = getConfigValue(CONFIG_KEY_BUILDER_API_KEY) val builderSecret = getConfigValue(CONFIG_KEY_BUILDER_SECRET) val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE) + val autoRedeem = isAutoRedeemEnabled() return SystemConfigDto( builderApiKeyConfigured = builderApiKey != null, builderSecretConfigured = builderSecret != null, - builderPassphraseConfigured = builderPassphrase != null + builderPassphraseConfigured = builderPassphrase != null, + autoRedeem = autoRedeem ) } @@ -83,9 +86,17 @@ class SystemConfigService( ) } + // 更新自动赎回配置 + if (request.autoRedeem != null) { + updateConfigValue( + CONFIG_KEY_AUTO_REDEEM, + request.autoRedeem.toString() + ) + } + Result.success(getSystemConfig()) } catch (e: Exception) { - logger.error("更新 Builder API Key 配置失败", e) + logger.error("更新系统配置失败", e) Result.failure(e) } } @@ -115,6 +126,32 @@ class SystemConfigService( return apiKey != null && secret != null && passphrase != null } + /** + * 检查自动赎回是否启用 + */ + fun isAutoRedeemEnabled(): Boolean { + val autoRedeemValue = getConfigValue(CONFIG_KEY_AUTO_REDEEM) + return when (autoRedeemValue?.lowercase()) { + "true" -> true + "false" -> false + else -> true // 默认开启 + } + } + + /** + * 更新自动赎回配置 + */ + @Transactional + fun updateAutoRedeem(enabled: Boolean): Result { + return try { + updateConfigValue(CONFIG_KEY_AUTO_REDEEM, enabled.toString()) + Result.success(getSystemConfig()) + } catch (e: Exception) { + logger.error("更新自动赎回配置失败", e) + Result.failure(e) + } + } + /** * 获取配置值(原始值,加密存储) */ @@ -141,6 +178,7 @@ class SystemConfigService( CONFIG_KEY_BUILDER_API_KEY -> "Builder API Key(用于 Gasless 交易)" CONFIG_KEY_BUILDER_SECRET -> "Builder Secret(用于 Gasless 交易)" CONFIG_KEY_BUILDER_PASSPHRASE -> "Builder Passphrase(用于 Gasless 交易)" + CONFIG_KEY_AUTO_REDEEM -> "自动赎回(系统级别配置,默认开启)" else -> null } ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt index d13230e..7722b40 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt @@ -70,7 +70,9 @@ class TelegramNotificationService( apiSecret: String? = null, apiPassphrase: String? = null, walletAddressForApi: String? = null, - locale: java.util.Locale? = null + locale: java.util.Locale? = null, + leaderName: String? = null, // Leader 名称(备注) + configName: String? = null // 跟单配置名 ) { // 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取) val currentLocale = locale ?: try { @@ -127,7 +129,9 @@ class TelegramNotificationService( amount = amount, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale + locale = currentLocale, + leaderName = leaderName, + configName = configName ) sendMessage(message) } @@ -405,8 +409,9 @@ class TelegramNotificationService( /** * 发送消息(发送给所有启用的 Telegram 配置) + * 公共方法,供其他服务调用 */ - private suspend fun sendMessage(message: String) { + suspend fun sendMessage(message: String) { try { val configs = notificationConfigService.getEnabledConfigsByType("telegram") if (configs.isEmpty()) { @@ -578,7 +583,9 @@ class TelegramNotificationService( amount: String?, accountName: String?, walletAddress: String?, - locale: java.util.Locale + locale: java.util.Locale, + leaderName: String? = null, // Leader 名称(备注) + configName: String? = null // 跟单配置名 ): String { // 获取多语言文本 @@ -617,11 +624,30 @@ class TelegramNotificationService( } } + // 构建跟单信息(如果有) + val copyTradingInfo = mutableListOf() + if (!configName.isNullOrBlank()) { + copyTradingInfo.add("配置: ${configName!!}") + } + if (!leaderName.isNullOrBlank()) { + copyTradingInfo.add("Leader: ${leaderName!!}") + } + val copyTradingInfoText = if (copyTradingInfo.isNotEmpty()) { + "\n• 跟单: ${copyTradingInfo.joinToString(", ")}" + } else { + "" + } + 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 escapedCopyTradingInfo = if (copyTradingInfoText.isNotEmpty()) { + copyTradingInfoText.replace("<", "<").replace(">", ">") + } else { + "" + } // 格式化金额显示 val amountDisplay = if (amount != null) { @@ -675,7 +701,7 @@ class TelegramNotificationService( • $priceLabel: $price • $quantityLabel: $size shares • $amountLabel: $amountDisplay USDC -• $accountLabel: $escapedAccountInfo +• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo ⏰ $timeLabel: $time""" } @@ -806,6 +832,128 @@ class TelegramNotificationService( ⏰ $timeLabel: $time""" } + /** + * 发送仓位赎回通知 + * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) + */ + suspend fun sendRedeemNotification( + accountName: String?, + walletAddress: String?, + transactionHash: String, + totalRedeemedValue: String, + positions: List, + 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") // 默认简体中文 + } + + val message = buildRedeemMessage( + accountName = accountName, + walletAddress = walletAddress, + transactionHash = transactionHash, + totalRedeemedValue = totalRedeemedValue, + positions = positions, + locale = currentLocale + ) + sendMessage(message) + } + + /** + * 构建仓位赎回消息 + */ + private fun buildRedeemMessage( + accountName: String?, + walletAddress: String?, + transactionHash: String, + totalRedeemedValue: String, + positions: List, + locale: java.util.Locale + ): String { + // 获取多语言文本 + val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale) + val redeemInfo = messageSource.getMessage("notification.redeem.info", null, "赎回信息", locale) + val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale) + val transactionHashLabel = messageSource.getMessage("notification.redeem.transaction_hash", null, "交易哈希", locale) + val totalValueLabel = messageSource.getMessage("notification.redeem.total_value", null, "赎回总价值", locale) + val positionsLabel = messageSource.getMessage("notification.redeem.positions", null, "赎回仓位", locale) + val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale) + val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale) + val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale) + val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" + + // 优先使用账户名称,如果没有账户名称才显示钱包地址 + 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 escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">") + val escapedTxHash = transactionHash.replace("<", "<").replace(">", ">") + + // 格式化金额显示 + val totalValueDisplay = try { + val totalValueDecimal = totalRedeemedValue.toSafeBigDecimal() + val formatted = if (totalValueDecimal.scale() > 4) { + totalValueDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + totalValueDecimal.stripTrailingZeros() + } + formatted.toPlainString() + } catch (e: Exception) { + totalRedeemedValue + } + + // 构建仓位列表 + val positionsText = positions.joinToString("\n") { position -> + val quantityDisplay = try { + val quantityDecimal = position.quantity.toSafeBigDecimal() + quantityDecimal.stripTrailingZeros().toPlainString() + } catch (e: Exception) { + position.quantity + } + val valueDisplay = try { + val valueDecimal = position.value.toSafeBigDecimal() + val formatted = if (valueDecimal.scale() > 4) { + valueDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + valueDecimal.stripTrailingZeros() + } + formatted.toPlainString() + } catch (e: Exception) { + position.value + } + " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC" + } + + return """✅ $redeemSuccess + +📊 $redeemInfo: +• $accountLabel: $escapedAccountInfo +• $transactionHashLabel: $escapedTxHash +• $totalValueLabel: $totalValueDisplay USDC + +📦 $positionsLabel: +$positionsText + +⏰ $timeLabel: $time""" + } + /** * 脱敏显示地址(只显示前6位和后4位) */ diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c473d9f..6f74aa3 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -47,11 +47,9 @@ polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com} # Builder Relayer 配置(用于 Gasless 交易) # 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证 +# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中 # 如果未配置,将使用手动发送交易的方式(需要用户支付 gas) polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/} -polymarket.builder.api-key=${POLYMARKET_BUILDER_API_KEY:} -polymarket.builder.secret=${POLYMARKET_BUILDER_SECRET:} -polymarket.builder.passphrase=${POLYMARKET_BUILDER_PASSPHRASE:} # 仓位推送配置 # 轮询间隔(毫秒),默认3秒 diff --git a/backend/src/main/resources/db/migration/V8__add_copy_trading_config_fields.sql b/backend/src/main/resources/db/migration/V8__add_copy_trading_config_fields.sql new file mode 100644 index 0000000..7f958c6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__add_copy_trading_config_fields.sql @@ -0,0 +1,19 @@ +-- ============================================ +-- V8: 添加跟单配置新字段 +-- 1. config_name: 配置名(可选) +-- 2. push_failed_orders: 推送失败订单(默认关闭) +-- 3. auto_redeem: 自动赎回(默认开启) +-- ============================================ + +-- 添加配置名字段 +ALTER TABLE copy_trading +ADD COLUMN config_name VARCHAR(255) NULL COMMENT '配置名(可选)'; + +-- 添加推送失败订单字段(默认关闭) +ALTER TABLE copy_trading +ADD COLUMN push_failed_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送失败订单(默认关闭)'; + +-- 添加自动赎回字段(默认开启) +ALTER TABLE copy_trading +ADD COLUMN auto_redeem BOOLEAN NOT NULL DEFAULT TRUE COMMENT '自动赎回(默认开启)'; + diff --git a/backend/src/main/resources/db/migration/V9__move_auto_redeem_to_system_config.sql b/backend/src/main/resources/db/migration/V9__move_auto_redeem_to_system_config.sql new file mode 100644 index 0000000..c670522 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__move_auto_redeem_to_system_config.sql @@ -0,0 +1,30 @@ +-- ============================================ +-- V9: 将自动赎回从跟单配置迁移到系统配置 +-- 1. 从 copy_trading 表中读取 auto_redeem 值,迁移到 system_config +-- 2. 移除 copy_trading.auto_redeem 字段 +-- ============================================ + +-- 1. 初始化 auto_redeem 配置项到 system_config(如果不存在) +-- 默认值为 true(因为之前 copy_trading.auto_redeem 默认是 true) +INSERT IGNORE INTO system_config (config_key, config_value, description, created_at, updated_at) +VALUES + ('auto_redeem', 'true', '自动赎回(系统级别配置,默认开启)', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000); + +-- 2. 如果 copy_trading 表中存在数据,检查是否有 auto_redeem = false 的配置 +-- 如果所有配置都是 true,则保持 system_config 中的值为 true +-- 如果存在 false,则设置为 false(保守策略:只要有一个配置是 false,就设置为 false) +UPDATE system_config +SET config_value = CASE + WHEN EXISTS ( + SELECT 1 FROM copy_trading + WHERE auto_redeem = FALSE + ) THEN 'false' + ELSE 'true' +END, +updated_at = UNIX_TIMESTAMP() * 1000 +WHERE config_key = 'auto_redeem'; + +-- 3. 移除 copy_trading 表中的 auto_redeem 字段 +ALTER TABLE copy_trading +DROP COLUMN auto_redeem; + diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 79a756d..93783ef 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -16,6 +16,32 @@ notification.order.time=Time notification.order.error_info=Error Information notification.order.unknown_account=Unknown Account notification.order.calculate_failed=Calculation Failed +notification.redeem.success=Position Redeemed Successfully +notification.redeem.info=Redeem Information +notification.redeem.transaction_hash=Transaction Hash +notification.redeem.total_value=Total Redeemed Value +notification.redeem.position_count=Position Count +notification.redeem.positions=Redeemed Positions +notification.redeem.account=Account +notification.redeem.time=Time + +# Auto Redeem related notifications +notification.auto_redeem.disabled.title=Auto Redeem Disabled +notification.auto_redeem.disabled.account=Account +notification.auto_redeem.disabled.redeemable_positions=Redeemable Positions +notification.auto_redeem.disabled.total_value=Total Value +notification.auto_redeem.disabled.message=Please enable auto redeem in system settings. +notification.auto_redeem.disabled.positions_unit= + +# Builder API Key related notifications +notification.builder_api_key.not_configured.title=Builder API Key Not Configured +notification.builder_api_key.not_configured.account=Account +notification.builder_api_key.not_configured.copy_trading_config=Copy Trading Config +notification.builder_api_key.not_configured.redeemable_positions=Redeemable Positions +notification.builder_api_key.not_configured.total_value=Total Value +notification.builder_api_key.not_configured.message=Please configure Builder API Key in system settings to enable auto redeem. +notification.builder_api_key.not_configured.positions_unit= +notification.builder_api_key.not_configured.unknown_config=Unnamed Config # Common common.unknown=Unknown diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index c4143f7..6132bc5 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -16,6 +16,32 @@ notification.order.time=时间 notification.order.error_info=错误信息 notification.order.unknown_account=未知账户 notification.order.calculate_failed=计算失败 +notification.redeem.success=仓位赎回成功 +notification.redeem.info=赎回信息 +notification.redeem.transaction_hash=交易哈希 +notification.redeem.total_value=赎回总价值 +notification.redeem.position_count=仓位数量 +notification.redeem.positions=赎回仓位 +notification.redeem.account=账户 +notification.redeem.time=时间 + +# 自动赎回相关通知 +notification.auto_redeem.disabled.title=自动赎回未开启 +notification.auto_redeem.disabled.account=账户 +notification.auto_redeem.disabled.redeemable_positions=可赎回仓位 +notification.auto_redeem.disabled.total_value=总价值 +notification.auto_redeem.disabled.message=请在系统设置中开启自动赎回功能。 +notification.auto_redeem.disabled.positions_unit=个 + +# Builder API Key 相关通知 +notification.builder_api_key.not_configured.title=Builder API Key 未配置 +notification.builder_api_key.not_configured.account=账户 +notification.builder_api_key.not_configured.copy_trading_config=跟单配置 +notification.builder_api_key.not_configured.redeemable_positions=可赎回仓位 +notification.builder_api_key.not_configured.total_value=总价值 +notification.builder_api_key.not_configured.message=请在系统设置中配置 Builder API Key 以启用自动赎回功能。 +notification.builder_api_key.not_configured.positions_unit=个 +notification.builder_api_key.not_configured.unknown_config=未命名配置 # 通用 common.unknown=未知 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 3b68739..1d96df8 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -16,6 +16,32 @@ notification.order.time=時間 notification.order.error_info=錯誤信息 notification.order.unknown_account=未知賬戶 notification.order.calculate_failed=計算失敗 +notification.redeem.success=倉位贖回成功 +notification.redeem.info=贖回信息 +notification.redeem.transaction_hash=交易哈希 +notification.redeem.total_value=贖回總價值 +notification.redeem.position_count=倉位數量 +notification.redeem.positions=贖回倉位 +notification.redeem.account=賬戶 +notification.redeem.time=時間 + +# 自動贖回相關通知 +notification.auto_redeem.disabled.title=自動贖回未開啟 +notification.auto_redeem.disabled.account=賬戶 +notification.auto_redeem.disabled.redeemable_positions=可贖回倉位 +notification.auto_redeem.disabled.total_value=總價值 +notification.auto_redeem.disabled.message=請在系統設置中開啟自動贖回功能。 +notification.auto_redeem.disabled.positions_unit=個 + +# Builder API Key 相關通知 +notification.builder_api_key.not_configured.title=Builder API Key 未配置 +notification.builder_api_key.not_configured.account=賬戶 +notification.builder_api_key.not_configured.copy_trading_config=跟單配置 +notification.builder_api_key.not_configured.redeemable_positions=可贖回倉位 +notification.builder_api_key.not_configured.total_value=總價值 +notification.builder_api_key.not_configured.message=請在系統設置中配置 Builder API Key 以啟用自動贖回功能。 +notification.builder_api_key.not_configured.positions_unit=個 +notification.builder_api_key.not_configured.unknown_config=未命名配置 # 通用 common.unknown=未知 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a4f3ad0..4476d34 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -92,7 +92,7 @@ function App() { * 处理订单推送消息,显示全局通知 */ const handleOrderPush = useCallback((message: OrderPushMessage) => { - const { accountName, order, orderDetail } = message + const { accountName, order, orderDetail, leaderName, configName } = message // 根据订单类型和操作类型确定通知内容 const orderTypeText = getOrderTypeText(order.type) @@ -100,7 +100,21 @@ function App() { // 如果有市场名称,在标题中显示 const marketName = orderDetail?.marketName || order.market.substring(0, 8) + '...' - const title = `${accountName} - ${orderTypeText}` + + // 构建标题:如果是跟单订单,显示 leader 备注和跟单配置名 + let title = `${accountName} - ${orderTypeText}` + if (leaderName || configName) { + const parts: string[] = [] + if (configName) { + parts.push(configName) + } + if (leaderName) { + parts.push(`Leader: ${leaderName}`) + } + if (parts.length > 0) { + title = `${accountName} (${parts.join(', ')}) - ${orderTypeText}` + } + } // 优先使用订单详情中的数据,如果没有则使用 WebSocket 消息中的数据 const price = orderDetail ? parseFloat(orderDetail.price).toFixed(4) : parseFloat(order.price).toFixed(4) diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index b9a7587..dac52a5 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -304,6 +304,18 @@ "systemSettingsDesc": "Configure proxy, view API health status", "footer": "Please use the above pages for configuration management." }, + "systemSettings": { + "autoRedeem": { + "title": "Auto Redeem Configuration", + "label": "Auto Redeem", + "tooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect", + "builderApiKeyNotConfigured": "Builder API Key Not Configured", + "builderApiKeyNotConfiguredDesc": "Auto redeem feature requires Builder API Key configuration to take effect.", + "goToConfigure": "Go to Configure", + "saveSuccess": "Auto redeem configuration updated", + "saveFailed": "Failed to update auto redeem configuration" + } + }, "builderApiKey": { "title": "Builder API Key Configuration", "alertTitle": "What is Builder API Key?", @@ -326,6 +338,7 @@ "saveSuccess": "Builder API Key configuration saved successfully", "saveFailed": "Failed to save Builder API Key configuration", "getFailed": "Failed to get Builder API Key configuration", + "noChanges": "No fields to update", "notConfigured": "Builder API Key not configured, please go to System Settings to configure", "notConfiguredError": "Builder API Key not configured, cannot execute Gasless transactions. Please go to System Settings to configure Builder API Key.", "apiReference": "API Reference Documentation", @@ -654,6 +667,18 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "configName": "Configuration Name", + "configNameRequired": "Please enter configuration name", + "configNamePlaceholder": "e.g., Copy Trading Config 1", + "configNameTooltip": "Set a name for the copy trading configuration for easy identification and management", + "advancedSettings": "Advanced Settings", + "pushFailedOrders": "Push Failed Orders", + "pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram", + "autoRedeem": "Auto Redeem", + "autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect", + "builderApiKeyNotConfigured": "Builder API Key Not Configured", + "builderApiKeyNotConfiguredDesc": "Auto redeem feature requires Builder API Key configuration to take effect.", + "goToConfigure": "Go to Configure", "supportSell": "Support Sell", "supportSellTooltip": "Whether to copy Leader's sell orders", "create": "Create Copy Trading Config", @@ -717,6 +742,18 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "configName": "Configuration Name", + "configNameRequired": "Please enter configuration name", + "configNamePlaceholder": "e.g., Copy Trading Config 1", + "configNameTooltip": "Set a name for the copy trading configuration for easy identification and management", + "advancedSettings": "Advanced Settings", + "pushFailedOrders": "Push Failed Orders", + "pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram", + "autoRedeem": "Auto Redeem", + "autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect", + "builderApiKeyNotConfigured": "Builder API Key Not Configured", + "builderApiKeyNotConfiguredDesc": "Auto redeem feature requires Builder API Key configuration to take effect.", + "goToConfigure": "Go to Configure", "supportSell": "Support Sell", "supportSellTooltip": "Whether to copy Leader's sell orders", "save": "Save", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 4839d69..5195545 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -216,6 +216,18 @@ "systemSettingsDesc": "配置代理、查看 API 健康状态", "footer": "请使用上述页面进行配置管理。" }, + "systemSettings": { + "autoRedeem": { + "title": "自动赎回配置", + "label": "自动赎回", + "tooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效", + "builderApiKeyNotConfigured": "Builder API Key 未配置", + "builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。", + "goToConfigure": "前往配置", + "saveSuccess": "自动赎回配置已更新", + "saveFailed": "更新自动赎回配置失败" + } + }, "builderApiKey": { "title": "Builder API Key 配置", "alertTitle": "什么是 Builder API Key?", @@ -238,6 +250,7 @@ "saveSuccess": "保存 Builder API Key 配置成功", "saveFailed": "保存 Builder API Key 配置失败", "getFailed": "获取 Builder API Key 配置失败", + "noChanges": "没有需要更新的字段", "notConfigured": "Builder API Key 未配置,请前往系统设置页面配置", "notConfiguredError": "Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。", "apiReference": "API 参考文档", @@ -508,6 +521,18 @@ "copyTradingAdd": { "title": "新增跟单配置", "back": "返回", + "configName": "配置名", + "configNameRequired": "请输入配置名", + "configNamePlaceholder": "例如:跟单配置1", + "configNameTooltip": "为跟单配置设置一个名称,便于识别和管理", + "advancedSettings": "高级设置", + "pushFailedOrders": "推送失败订单", + "pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram", + "autoRedeem": "自动赎回", + "autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效", + "builderApiKeyNotConfigured": "Builder API Key 未配置", + "builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。", + "goToConfigure": "前往配置", "selectWallet": "选择钱包", "selectWalletPlaceholder": "请选择钱包", "walletRequired": "请选择钱包", @@ -579,6 +604,18 @@ "copyTradingEdit": { "title": "编辑跟单配置", "back": "返回", + "configName": "配置名", + "configNameRequired": "请输入配置名", + "configNamePlaceholder": "例如:跟单配置1", + "configNameTooltip": "为跟单配置设置一个名称,便于识别和管理", + "advancedSettings": "高级设置", + "pushFailedOrders": "推送失败订单", + "pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram", + "autoRedeem": "自动赎回", + "autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效", + "builderApiKeyNotConfigured": "Builder API Key 未配置", + "builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。", + "goToConfigure": "前往配置", "wallet": "钱包", "leader": "Leader", "selectWallet": "钱包", @@ -666,6 +703,8 @@ "copyTradingList": { "title": "跟单配置管理", "addCopyTrading": "新增跟单", + "configName": "配置名", + "configNameNotProvided": "未提供", "wallet": "钱包", "account": "账户", "template": "模板", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 6514a48..c2d31fa 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -304,6 +304,18 @@ "systemSettingsDesc": "配置代理、查看 API 健康狀態", "footer": "請使用上述頁面進行配置管理。" }, + "systemSettings": { + "autoRedeem": { + "title": "自動贖回配置", + "label": "自動贖回", + "tooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效", + "builderApiKeyNotConfigured": "Builder API Key 未配置", + "builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。", + "goToConfigure": "前往配置", + "saveSuccess": "自動贖回配置已更新", + "saveFailed": "更新自動贖回配置失敗" + } + }, "builderApiKey": { "title": "Builder API Key 配置", "alertTitle": "什麼是 Builder API Key?", @@ -326,6 +338,7 @@ "saveSuccess": "保存 Builder API Key 配置成功", "saveFailed": "保存 Builder API Key 配置失敗", "getFailed": "獲取 Builder API Key 配置失敗", + "noChanges": "沒有需要更新的字段", "notConfigured": "Builder API Key 未配置,請前往系統設置頁面配置", "notConfiguredError": "Builder API Key 未配置,無法執行 Gasless 交易。請前往系統設置頁面配置 Builder API Key。", "apiReference": "API 參考文檔", @@ -654,6 +667,18 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "configName": "配置名", + "configNameRequired": "請輸入配置名", + "configNamePlaceholder": "例如:跟單配置1", + "configNameTooltip": "為跟單配置設置一個名稱,便於識別和管理", + "advancedSettings": "高級設置", + "pushFailedOrders": "推送失敗訂單", + "pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram", + "autoRedeem": "自動贖回", + "autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效", + "builderApiKeyNotConfigured": "Builder API Key 未配置", + "builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。", + "goToConfigure": "前往配置", "supportSell": "跟單賣出", "supportSellTooltip": "是否跟單 Leader 的賣出訂單", "create": "創建跟單配置", @@ -717,6 +742,18 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "configName": "配置名", + "configNameRequired": "請輸入配置名", + "configNamePlaceholder": "例如:跟單配置1", + "configNameTooltip": "為跟單配置設置一個名稱,便於識別和管理", + "advancedSettings": "高級設置", + "pushFailedOrders": "推送失敗訂單", + "pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram", + "autoRedeem": "自動贖回", + "autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效", + "builderApiKeyNotConfigured": "Builder API Key 未配置", + "builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。", + "goToConfigure": "前往配置", "supportSell": "跟單賣出", "supportSellTooltip": "是否跟單 Leader 的賣出訂單", "save": "保存", diff --git a/frontend/src/pages/BuilderApiKeySettings.tsx b/frontend/src/pages/BuilderApiKeySettings.tsx index 376ad2d..a3d2154 100644 --- a/frontend/src/pages/BuilderApiKeySettings.tsx +++ b/frontend/src/pages/BuilderApiKeySettings.tsx @@ -25,11 +25,11 @@ const BuilderApiKeySettings: React.FC = () => { if (response.data.code === 0 && response.data.data) { const config = response.data.data setBuilderApiKeyConfig(config) - // 预填充字段(如果已配置,显示占位符) + // 如果已配置,输入框留空(不显示***) builderApiKeyForm.setFieldsValue({ - builderApiKey: config.builderApiKeyConfigured ? '***' : '', - builderSecret: config.builderSecretConfigured ? '***' : '', - builderPassphrase: config.builderPassphraseConfigured ? '***' : '', + builderApiKey: '', + builderSecret: '', + builderPassphrase: '', }) } else { message.error(response.data.msg || t('builderApiKey.getFailed')) @@ -42,16 +42,23 @@ const BuilderApiKeySettings: React.FC = () => { const handleBuilderApiKeySubmit = async (values: BuilderApiKeyUpdateRequest) => { setBuilderApiKeyLoading(true) try { - // 如果值是 '***',表示已配置但未修改,不发送 + // 只发送非空字段(如果字段为空且已配置,表示不修改,不发送该字段) const updateData: BuilderApiKeyUpdateRequest = {} - if (values.builderApiKey && values.builderApiKey !== '***') { - updateData.builderApiKey = values.builderApiKey + if (values.builderApiKey && values.builderApiKey.trim()) { + updateData.builderApiKey = values.builderApiKey.trim() } - if (values.builderSecret && values.builderSecret !== '***') { - updateData.builderSecret = values.builderSecret + if (values.builderSecret && values.builderSecret.trim()) { + updateData.builderSecret = values.builderSecret.trim() } - if (values.builderPassphrase && values.builderPassphrase !== '***') { - updateData.builderPassphrase = values.builderPassphrase + if (values.builderPassphrase && values.builderPassphrase.trim()) { + updateData.builderPassphrase = values.builderPassphrase.trim() + } + + // 如果所有字段都为空,提示用户 + if (!updateData.builderApiKey && !updateData.builderSecret && !updateData.builderPassphrase) { + message.warning(t('builderApiKey.noChanges') || '没有需要更新的字段') + setBuilderApiKeyLoading(false) + return } const response = await apiService.systemConfig.updateBuilderApiKey(updateData) diff --git a/frontend/src/pages/CopyTradingAdd.tsx b/frontend/src/pages/CopyTradingAdd.tsx index d2b01d1..4c151b0 100644 --- a/frontend/src/pages/CopyTradingAdd.tsx +++ b/frontend/src/pages/CopyTradingAdd.tsx @@ -22,10 +22,31 @@ const CopyTradingAdd: React.FC = () => { const [templateModalVisible, setTemplateModalVisible] = useState(false) const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + // 生成默认配置名 + const generateDefaultConfigName = (): string => { + const now = new Date() + const dateStr = now.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).replace(/\//g, '-') + const timeStr = now.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + return `跟单配置-${dateStr}-${timeStr}` + } + useEffect(() => { fetchAccounts() fetchLeaders() fetchTemplates() + + // 生成默认配置名 + const defaultConfigName = generateDefaultConfigName() + form.setFieldsValue({ configName: defaultConfigName }) }, []) const fetchLeaders = async () => { @@ -114,7 +135,9 @@ const CopyTradingAdd: React.FC = () => { maxSpread: values.maxSpread?.toString(), minOrderbookDepth: values.minOrderbookDepth?.toString(), minPrice: values.minPrice?.toString(), - maxPrice: values.maxPrice?.toString() + maxPrice: values.maxPrice?.toString(), + configName: values.configName?.trim(), + pushFailedOrders: values.pushFailedOrders ?? false } const response = await apiService.copyTrading.create(request) @@ -163,10 +186,26 @@ const CopyTradingAdd: React.FC = () => { useWebSocket: true, websocketReconnectInterval: 5000, websocketMaxRetries: 10, - supportSell: true + supportSell: true, + pushFailedOrders: false }} > {/* 基础信息 */} + + + + { - {/* 跟单卖出 - 表单最底部 */} + {t('copyTradingAdd.advancedSettings') || '高级设置'} + + {/* 跟单卖出 */} { + {/* 推送失败订单 */} + + + + + + } + type="warning" + showIcon + style={{ marginBottom: '16px' }} + /> + )} + + + + + + ) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a4e875c..0cad413 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -594,7 +594,25 @@ export const apiService = { * 更新 Builder API Key 配置 */ updateBuilderApiKey: (data: import('../types').BuilderApiKeyUpdateRequest) => - apiClient.post>('/system/config/builder-api-key/update', data) + apiClient.post>('/system/config/builder-api-key/update', data), + + /** + * 检查 Builder API Key 是否已配置 + */ + checkBuilderApiKey: () => + apiClient.post>('/system/config/builder-api-key/check', {}), + + /** + * 更新自动赎回配置 + */ + updateAutoRedeem: (data: { enabled: boolean }) => + apiClient.post>('/system/config/auto-redeem/update', data), + + /** + * 获取自动赎回状态 + */ + getAutoRedeemStatus: () => + apiClient.post>('/system/config/auto-redeem/status', {}) } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 64100f7..d783081 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -205,6 +205,9 @@ export interface CopyTrading { minOrderbookDepth?: string minPrice?: string // 最低价格(可选),NULL表示不限制最低价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + configName?: string // 配置名(可选,但提供时必须非空) + pushFailedOrders: boolean // 推送失败订单(默认关闭) createdAt: number updatedAt: number } @@ -246,6 +249,9 @@ export interface CopyTradingCreateRequest { minOrderbookDepth?: string minPrice?: string // 最低价格(可选),NULL表示不限制最低价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + configName?: string // 配置名(可选,但提供时必须非空) + pushFailedOrders?: boolean // 推送失败订单(可选) } /** @@ -275,6 +281,9 @@ export interface CopyTradingUpdateRequest { minOrderbookDepth?: string minPrice?: string // 最低价格(可选),NULL表示不限制最低价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 + // 新增配置字段 + configName?: string // 配置名(可选,但提供时必须非空) + pushFailedOrders?: boolean // 推送失败订单(可选) } /** @@ -497,6 +506,9 @@ export interface OrderPushMessage { order: OrderMessage // 订单信息(来自 WebSocket) orderDetail?: OrderDetail // 订单详情(通过 API 获取) timestamp?: number // 推送时间戳 + // 跟单相关字段(可选,仅在跟单触发的订单时提供) + leaderName?: string // Leader 名称(备注) + configName?: string // 跟单配置名 } /** @@ -760,6 +772,7 @@ export interface SystemConfig { builderApiKeyConfigured: boolean builderSecretConfigured: boolean builderPassphraseConfigured: boolean + autoRedeem: boolean // 自动赎回(系统级别配置,默认开启) } /**