feat: 优化跟单买入逻辑和错误处理

- 更新 Twitter 链接为 @polyhermes
- 简化订单创建失败日志,只保留 code 和 errorBody
- 修复 @Transactional 注解问题(类改为 open class)
- 改进唯一键冲突异常处理,支持并发场景
- 整理跟单买入和重试逻辑,使用常量配置
- 重试延迟从 1 秒调整为 3 秒
- 添加订单簿匹配检查,避免 FAK 订单失败
- 前端移除 Leader 管理的分类相关设置和显示
- 补充多语言翻译(AccountImport 和 CopyTradingList)
This commit is contained in:
WrBug
2025-12-11 02:58:50 +08:00
parent f8d7276654
commit 0625c62e36
13 changed files with 256 additions and 149 deletions
@@ -10,6 +10,8 @@ import com.wrbug.polymarketbot.util.*
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.dao.DuplicateKeyException
import java.sql.SQLException
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
@@ -27,7 +29,7 @@ import java.math.BigDecimal
* 实际创建订单并记录跟踪信息
*/
@Service
class CopyOrderTrackingService(
open class CopyOrderTrackingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
@@ -50,6 +52,12 @@ class CopyOrderTrackingService(
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 订单创建重试配置
companion object {
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
}
/**
* 解密账户私钥
@@ -144,21 +152,38 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(可能是 DataIntegrityViolationException、DuplicateKeyException 或 SQLException
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return Result.success(Unit)
}
// 如果还是查询不到,记录警告但不抛出异常(可能是其他约束冲突)
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 不抛出异常,避免影响其他交易的处理
return Result.success(Unit)
}
return Result.success(Unit)
} else {
// 如果检查不到,说明可能是其他约束冲突,重新抛出异常
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 其他类型的异常,重新抛出
throw e
}
}
@@ -358,6 +383,35 @@ class CopyOrderTrackingService(
// 计算价格(应用价格容忍度)
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
val orderbookForCheck = orderbook ?: run {
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (orderbookResult.isSuccess) {
orderbookResult.getOrNull()
} else {
null
}
}
// 检查是否有可匹配的卖单(asks)
if (orderbookForCheck != null) {
val bestAsk = orderbookForCheck.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestAsk == null) {
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
if (buyPrice.lt(bestAsk)) {
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
continue
}
}
// 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -383,7 +437,9 @@ class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名
// 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
val createOrderResult = createOrderWithRetry(
clobApi = clobApi,
privateKey = decryptedPrivateKey,
@@ -397,8 +453,9 @@ class CopyOrderTrackingService(
tradeId = trade.id
)
// 处理订单创建失败
if (createOrderResult.isFailure) {
// 创建订单失败,记录到失败表
// 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
@@ -407,16 +464,19 @@ class CopyOrderTrackingService(
finalBuyQuantity.toString(),
trade.id
)
// 记录失败交易到数据库
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
recordFailedTrade(
leaderId = leaderId,
trade = trade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "BUY", // 订单方向是BUY
side = "BUY",
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
errorMessage = errorMsg,
retryCount = 1 // 已重试
retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次
)
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
@@ -427,7 +487,8 @@ class CopyOrderTrackingService(
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
val marketResponse =
gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
@@ -544,7 +605,7 @@ class CopyOrderTrackingService(
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
val leaderName = leader?.leaderName
@@ -648,8 +709,8 @@ class CopyOrderTrackingService(
* 卖出订单匹配
* 统一按比例计算,不区分RATIO或FIXED模式
* 实际创建卖出订单并记录匹配关系
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
*/
@Transactional
private suspend fun matchSellOrder(
copyTrading: CopyTrading,
leaderSellTrade: TradeResponse
@@ -872,8 +933,23 @@ class CopyOrderTrackingService(
/**
* 创建订单(带重试机制)
* 失败后重试一次,如果仍然失败则返回失败结果
* 注意:重试时会重新生成salt并重新签名,确保每次重试都是新的订单
*
* 重试策略:
* - 最多重试 MAX_RETRY_ATTEMPTS 次(首次尝试 + 重试)
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
* - 每次重试都重新生成salt并重新签名,确保签名唯一性
*
* @param clobApi CLOB API 客户端
* @param privateKey 私钥(用于签名)
* @param makerAddress 代理钱包地址
* @param tokenId Token ID
* @param side 订单方向(BUY/SELL
* @param price 价格
* @param size 数量
* @param owner API Key(用于owner字段)
* @param copyTradingId 跟单配置ID(用于日志)
* @param tradeId Leader 交易ID(用于日志)
* @return 成功返回订单ID,失败返回异常
*/
private suspend fun createOrderWithRetry(
clobApi: PolymarketClobApi,
@@ -883,16 +959,16 @@ class CopyOrderTrackingService(
side: String,
price: String,
size: String,
owner: String, // API Key,用于owner字段
owner: String,
copyTradingId: Long,
tradeId: String
): Result<String> {
var lastError: Exception? = null
// 最多重试2次(首次 + 1次重试)
for (attempt in 1..2) {
// 重试循环:最多重试 MAX_RETRY_ATTEMPTS 次
for (attempt in 1..MAX_RETRY_ATTEMPTS) {
try {
// 每次重试都重新生成salt并重新签名
// 每次重试都重新生成salt并重新签名,确保签名唯一性
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = privateKey,
makerAddress = makerAddress,
@@ -906,76 +982,118 @@ class CopyOrderTrackingService(
expiration = "0"
)
// 构建订单请求(每次重试都使用新签名的订单)
// 构建订单请求
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = owner, // API Key
owner = owner,
orderType = "FAK", // Fill-And-Kill
deferExec = false
)
// 调用 API 创建订单
val orderResponse = clobApi.createOrder(orderRequest)
// 检查 HTTP 响应状态
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
val errorBody = try {
orderResponse.errorBody()?.string()
} catch (e: Exception) {
null
}
val errorMsg =
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError)
}
// 检查业务响应状态
val response = orderResponse.body()!!
if (!response.success || response.orderId == null) {
val errorMsg =
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}"
val errorMsg = "errorMsg=${response.errorMsg}"
lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError)
}
// 成功
// 创建订单成功
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
return Result.success(response.orderId)
} catch (e: Exception) {
val errorMsg =
"调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}"
val errorMsg = "error=${e.message}"
lastError = Exception(errorMsg, e)
// 所有失败都记录详细日志(包括堆栈)
logger.error(errorMsg, e)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志(包含堆栈)
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError)
}
}
val finalError = lastError ?: Exception("创建订单失败:未知错误")
logger.error(
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId",
finalError
)
// 所有重试都失败
val finalError = lastError ?: Exception("error=未知错误")
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
return Result.failure(finalError)
}
/**
* 构建完整的错误信息(包括堆栈)
* 检查是否是唯一键冲突异常
*/
private fun isUniqueConstraintViolation(e: Exception): Boolean {
// 检查是否是 DataIntegrityViolationException 或 DuplicateKeyException
if (e is DataIntegrityViolationException || e is DuplicateKeyException) {
return true
}
// 检查是否是 SQLExceptionMySQL 错误码 1062 表示重复键)
var cause: Throwable? = e.cause
while (cause != null) {
if (cause is SQLException) {
val sqlException = cause as SQLException
// MySQL 错误码 1062 表示重复键(Duplicate entry
if (sqlException.errorCode == 1062 || sqlException.sqlState == "23000") {
return true
}
}
// 检查异常消息中是否包含唯一键冲突的关键字
val message = cause.message ?: ""
if (message.contains("Duplicate entry") ||
message.contains("uk_leader_trade") ||
message.contains("UNIQUE constraint")
) {
return true
}
cause = cause.cause
}
return false
}
/**
* 构建简化的错误信息(只保留 code 和 errorBody
*/
private fun buildFullErrorMessage(
exception: Throwable?,
@@ -985,35 +1103,29 @@ class CopyOrderTrackingService(
tradeId: String
): String {
if (exception == null) {
return "创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId, 未知错误"
return "code=未知, errorBody=null"
}
val errorMsg = StringBuilder()
errorMsg.append("创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId")
errorMsg.append(", error=${exception.message}")
val exceptionMessage = exception.message ?: ""
// 添加堆栈信息(限制长度,避免过长)
val stackTrace = exception.stackTraceToString()
val maxLength = 2000 // 限制错误信息最大长度为2000字符
if (stackTrace.length > maxLength) {
errorMsg.append(", stackTrace=${stackTrace.substring(0, maxLength)}...")
} else {
errorMsg.append(", stackTrace=$stackTrace")
}
// 从错误信息中提取 code 和 errorBody
val codePattern = Regex("code=([^,}]+)")
val errorBodyPattern = Regex("errorBody=([^,}]+)")
// 如果有 cause,也添加
exception.cause?.let { cause ->
errorMsg.append(", cause=${cause.message}")
}
val codeMatch = codePattern.find(exceptionMessage)
val errorBodyMatch = errorBodyPattern.find(exceptionMessage)
return errorMsg.toString()
val code = codeMatch?.groupValues?.get(1)?.trim() ?: "未知"
val errorBody = errorBodyMatch?.groupValues?.get(1)?.trim() ?: "null"
return "code=$code, errorBody=$errorBody"
}
/**
* 记录失败交易到数据库
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
*/
@Transactional
private fun recordFailedTrade(
private suspend fun recordFailedTrade(
leaderId: Long,
trade: TradeResponse,
copyTradingId: Long,
@@ -1064,21 +1176,35 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} catch (e: Exception) {
// 检查是否是唯一键冲突异常
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
}
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
// 如果查询不到,等待一下再查询(可能是事务隔离级别问题)
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
}
}
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}