diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt index cca2976..3423cd7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -18,6 +18,9 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository + /** 根据订单 ID 查询尾盘触发记录(用于 WS 推送时匹配并发送 TG 通知) */ + fun findByOrderId(orderId: String): CryptoTailStrategyTrigger? + /** 策略已结算订单的总已实现盈亏(用于收益统计) */ @Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true") fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal? diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationSubscriber.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationSubscriber.kt new file mode 100644 index 0000000..cd99b4a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationSubscriber.kt @@ -0,0 +1,80 @@ +package com.wrbug.polymarketbot.service.cryptotail + +import com.wrbug.polymarketbot.dto.OrderPushMessage +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository +import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository +import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService +import com.wrbug.polymarketbot.service.system.TelegramNotificationService +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +/** + * 尾盘策略订单 TG 通知订阅者 + * 与跟单订单广播方式一致:通过 OrderPushService.subscribeAllEnabled 订阅订单推送, + * 收到广播后匹配是否为尾盘订单,若是则发送 TG 通知。 + */ +@Service +class CryptoTailOrderNotificationSubscriber( + private val orderPushService: OrderPushService, + private val triggerRepository: CryptoTailStrategyTriggerRepository, + private val strategyRepository: CryptoTailStrategyRepository, + private val accountRepository: AccountRepository, + private val telegramNotificationService: TelegramNotificationService +) { + + private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationSubscriber::class.java) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var orderPushCallback: ((OrderPushMessage) -> Unit)? = null + + @PostConstruct + fun subscribe() { + val callback: (OrderPushMessage) -> Unit = { message -> onOrderPush(message) } + orderPushCallback = callback + orderPushService.subscribeAllEnabled(callback) + logger.info("尾盘订单 TG 通知已订阅 OrderPushService 广播") + } + + @PreDestroy + fun unsubscribe() { + orderPushCallback?.let { orderPushService.unsubscribeAll(it) } + orderPushCallback = null + logger.info("尾盘订单 TG 通知已取消订阅") + } + + private fun onOrderPush(message: OrderPushMessage) { + val trigger = triggerRepository.findByOrderId(message.order.id) ?: return + val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return + val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return + val orderTimeMs = message.order.timestamp.toLongOrNull()?.let { ts -> + if (ts < 1_000_000_000_000L) ts * 1000 else ts + } + scope.launch { + try { + telegramNotificationService.sendCryptoTailOrderSuccessNotification( + orderId = message.order.id, + marketTitle = trigger.marketTitle ?: message.orderDetail?.marketName ?: "", + marketId = message.order.market, + marketSlug = message.orderDetail?.marketSlug, + side = message.order.side, + outcome = message.order.outcome, + price = message.order.price, + size = message.order.originalSize, + strategyName = strategy.name, + accountName = account.accountName, + walletAddress = account.walletAddress, + orderTime = orderTimeMs + ) + } catch (e: Exception) { + logger.warn("尾盘订单 TG 通知失败: orderId=${message.order.id}, ${e.message}", e) + } + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index e02b2de..46ca2e7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -303,6 +303,69 @@ class TelegramNotificationService( sendMessage(message) } + /** + * 发送尾盘策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配尾盘订单后调用) + */ + suspend fun sendCryptoTailOrderSuccessNotification( + orderId: String?, + marketTitle: String, + marketId: String? = null, + marketSlug: String? = null, + side: String, + outcome: String? = null, + price: String, + size: String, + strategyName: String? = null, + accountName: String? = null, + walletAddress: String? = null, + locale: java.util.Locale? = null, + orderTime: Long? = null + ) { + if (orderId != null) { + val lastSentTime = sentOrderIds[orderId] + if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) { + logger.info("尾盘订单通知已发送过(5分钟内),跳过: orderId=$orderId") + return + } + sentOrderIds[orderId] = System.currentTimeMillis() + if (sentOrderIds.size > 1000) { + val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000 + sentOrderIds.entries.removeIf { it.value < expiryTime } + } + } + val currentLocale = locale ?: try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e) + java.util.Locale("zh", "CN") + } + 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 = buildCryptoTailOrderSuccessMessage( + orderId = orderId, + marketTitle = marketTitle, + marketId = marketId, + marketSlug = marketSlug, + side = side, + outcome = outcome, + price = price, + size = size, + amount = amount, + strategyName = strategyName, + accountName = accountName, + walletAddress = walletAddress, + locale = currentLocale, + orderTime = orderTime + ) + sendMessage(message) + } + /** * 构建订单被过滤消息 */ @@ -827,6 +890,85 @@ class TelegramNotificationService( • $amountLabel: $amountDisplay USDC • $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo +⏰ $timeLabel: $time""" + } + + /** + * 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名) + */ + private fun buildCryptoTailOrderSuccessMessage( + orderId: String?, + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + strategyName: String?, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + orderTime: Long? + ): String { + val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "尾盘策略下单成功", locale) + val strategyLabel = messageSource.getMessage("notification.tail.strategy", 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: String = messageSource.getMessage("common.unknown", null, "未知", locale) ?: "未知" + val unknownAccount = 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 = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime() + val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">") + val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">") + val strategyDisplay = strategyName?.takeIf { it.isNotBlank() } ?: unknown + val escapedStrategyName = strategyDisplay.replace("<", "<").replace(">", ">") + val amountDisplay = if (amount != null) { + try { + val amountDecimal = amount.toSafeBigDecimal() + val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros() + formatted.toPlainString() + } catch (e: Exception) { amount } + } else calculateFailed + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> null + } + val marketDisplay = if (marketLink != null) "$escapedMarketTitle" else escapedMarketTitle + val outcomeDisplay = if (!outcome.isNullOrBlank()) { + val escapedOutcome = outcome.replace("<", "<").replace(">", ">") + "\n• $outcomeLabel: $escapedOutcome" + } else "" + val priceDisplay = formatPrice(price) + val sizeDisplay = formatQuantity(size) + return """🚀 $tailOrderSuccess + +📊 $orderInfo: +• $orderIdLabel: ${orderId ?: unknown} +• $strategyLabel: $escapedStrategyName +• $marketLabel: $marketDisplay$outcomeDisplay +• $sideLabel: $sideDisplay +• $priceLabel: $priceDisplay +• $quantityLabel: $sizeDisplay shares +• $amountLabel: $amountDisplay USDC +• $accountLabel: $escapedAccountInfo + ⏰ $timeLabel: $time""" } diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 450b981..484dd47 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -16,6 +16,8 @@ notification.order.time=Time notification.order.error_info=Error Information notification.order.unknown_account=Unknown Account notification.order.calculate_failed=Calculation Failed +notification.tail.order.success=Tail Session Order Success +notification.tail.strategy=Strategy notification.redeem.success=Position Redeemed Successfully notification.redeem.info=Redeem Information notification.redeem.transaction_hash=Transaction Hash diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index b85d474..c3c9d6c 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -16,6 +16,8 @@ notification.order.time=时间 notification.order.error_info=错误信息 notification.order.unknown_account=未知账户 notification.order.calculate_failed=计算失败 +notification.tail.order.success=尾盘策略下单成功 +notification.tail.strategy=策略 notification.redeem.success=仓位赎回成功 notification.redeem.info=赎回信息 notification.redeem.transaction_hash=交易哈希 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index c89ed19..0547847 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -16,6 +16,8 @@ notification.order.time=時間 notification.order.error_info=錯誤信息 notification.order.unknown_account=未知賬戶 notification.order.calculate_failed=計算失敗 +notification.tail.order.success=尾盤策略下單成功 +notification.tail.strategy=策略 notification.redeem.success=倉位贖回成功 notification.redeem.info=贖回信息 notification.redeem.transaction_hash=交易哈希