feat(cryptotail): 尾盘下单成功 TG 通知,通过 subscribe 订阅订单广播

- CryptoTailStrategyTriggerRepository 新增 findByOrderId 用于 WS 匹配
- TelegramNotificationService 新增 sendCryptoTailOrderSuccessNotification 及多语言
- 新增 CryptoTailOrderNotificationSubscriber,与跟单一致通过 OrderPushService.subscribeAllEnabled 订阅订单推送,匹配尾盘订单后发 TG
- i18n: notification.tail.order.success, notification.tail.strategy (zh-CN/zh-TW/en)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-14 07:57:52 +08:00
co-authored by Cursor
parent bf0e52fc01
commit 9413507997
6 changed files with 231 additions and 0 deletions
@@ -18,6 +18,9 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
/** 根据订单 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?
@@ -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)
}
}
}
}
@@ -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: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
$timeLabel: <code>$time</code>"""
}
/**
* 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名)
*/
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("<", "&lt;").replace(">", "&gt;")
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
val strategyDisplay = strategyName?.takeIf { it.isNotBlank() } ?: unknown
val escapedStrategyName = strategyDisplay.replace("<", "&lt;").replace(">", "&gt;")
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) "<a href=\"$marketLink\">$escapedMarketTitle</a>" else escapedMarketTitle
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
val escapedOutcome = outcome.replace("<", "&lt;").replace(">", "&gt;")
"\n$outcomeLabel: <b>$escapedOutcome</b>"
} else ""
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """🚀 <b>$tailOrderSuccess</b>
📊 <b>$orderInfo</b>
$orderIdLabel: <code>${orderId ?: unknown}</code>
$strategyLabel: $escapedStrategyName
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo
$timeLabel: <code>$time</code>"""
}
@@ -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
@@ -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=交易哈希
@@ -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=交易哈希