feat: 重构仓位轮训逻辑并修复多语言配置
主要改动: 1. 创建独立的 PositionCheckService 服务 - 将仓位检查逻辑从 PositionPushService 中分离 - 实现待赎回仓位检查和未卖出订单检查 - 实现订单状态更新逻辑(FIFO策略) 2. 修改 PositionPushService - 后端启动时自动启动轮训任务 - 每次轮训后推送全量数据给所有订阅的客户端 - 调用 PositionCheckService 进行仓位检查 3. 自动赎回功能调整 - 将自动赎回从跟单配置级别移到系统级别 - 添加系统级自动赎回配置管理 - 支持 Builder API Key 配置检查 4. 多语言配置修复 - 修复 PositionCheckService 中的硬编码消息 - 使用 MessageSource 获取多语言文本 - 添加自动赎回和 Builder API Key 相关的多语言资源 5. 数据库迁移 - V8: 添加跟单配置名称字段 - V9: 将自动赎回配置移到系统配置表 6. 前端更新 - 添加系统级自动赎回配置界面 - 更新跟单配置管理界面 - 添加 Builder API Key 配置界面
This commit is contained in:
@@ -65,5 +65,66 @@ class SystemConfigController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Builder API Key 是否已配置
|
||||
*/
|
||||
@PostMapping("/builder-api-key/check")
|
||||
fun checkBuilderApiKey(): ResponseEntity<ApiResponse<Map<String, Boolean>>> {
|
||||
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<String, Boolean>): ResponseEntity<ApiResponse<SystemConfigDto>> {
|
||||
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<ApiResponse<Map<String, Boolean>>> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 // 跟单配置名
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
+5
@@ -45,5 +45,10 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
* 根据Leader交易ID查询订单
|
||||
*/
|
||||
fun findByLeaderBuyTradeId(leaderBuyTradeId: String): CopyOrderTracking?
|
||||
|
||||
/**
|
||||
* 根据买入订单ID查询订单跟踪记录
|
||||
*/
|
||||
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+47
-38
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
// 推送给所有订阅者
|
||||
|
||||
@@ -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<String, Long>() // "accountId_marketId_outcomeIndex" -> lastNotificationTime
|
||||
|
||||
// 记录已发送提示的配置(避免重复推送)
|
||||
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // 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<AccountPositionDto>) {
|
||||
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<AccountPositionDto>) {
|
||||
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<Pair<AccountPositionDto, List<CopyOrderTracking>>>()
|
||||
val positionsWithoutOrders = mutableListOf<AccountPositionDto>()
|
||||
|
||||
for (position in positions) {
|
||||
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
||||
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
||||
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<AccountPositionDto>) {
|
||||
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<CopyOrderTracking>,
|
||||
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<CopyOrderTracking>,
|
||||
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<CopyOrderTracking>,
|
||||
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<AccountPositionDto>) {
|
||||
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<AccountPositionDto>
|
||||
) {
|
||||
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)}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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<SystemConfigDto> {
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
+153
-5
@@ -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<String>()
|
||||
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: <code>$price</code>
|
||||
• $quantityLabel: <code>$size</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
@@ -806,6 +832,128 @@ class TelegramNotificationService(
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送仓位赎回通知
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
*/
|
||||
suspend fun sendRedeemNotification(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
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<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
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 """✅ <b>$redeemSuccess</b>
|
||||
|
||||
📊 <b>$redeemInfo:</b>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $transactionHashLabel: <code>$escapedTxHash</code>
|
||||
• $totalValueLabel: <code>$totalValueDisplay</code> USDC
|
||||
|
||||
📦 <b>$positionsLabel:</b>
|
||||
$positionsText
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏显示地址(只显示前6位和后4位)
|
||||
*/
|
||||
|
||||
@@ -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秒
|
||||
|
||||
@@ -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 '自动赎回(默认开启)';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=未知
|
||||
|
||||
@@ -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=未知
|
||||
|
||||
+16
-2
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "模板",
|
||||
|
||||
@@ -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": "保存",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
{/* 基础信息 */}
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.configName') || '配置名'}
|
||||
name="configName"
|
||||
rules={[
|
||||
{ required: true, message: t('copyTradingAdd.configNameRequired') || '请输入配置名' },
|
||||
{ whitespace: true, message: t('copyTradingAdd.configNameRequired') || '配置名不能为空' }
|
||||
]}
|
||||
tooltip={t('copyTradingAdd.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('copyTradingAdd.configNamePlaceholder') || '例如:跟单配置1'}
|
||||
maxLength={255}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.selectWallet') || '选择钱包'}
|
||||
name="accountId"
|
||||
@@ -444,7 +483,9 @@ const CopyTradingAdd: React.FC = () => {
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* 跟单卖出 - 表单最底部 */}
|
||||
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
|
||||
|
||||
{/* 跟单卖出 */}
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.supportSell') || '跟单卖出'}
|
||||
name="supportSell"
|
||||
@@ -454,6 +495,16 @@ const CopyTradingAdd: React.FC = () => {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送失败订单 */}
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.pushFailedOrders') || '推送失败订单'}
|
||||
name="pushFailedOrders"
|
||||
tooltip={t('copyTradingAdd.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -58,7 +58,9 @@ const CopyTradingEdit: React.FC = () => {
|
||||
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
|
||||
minOrderbookDepth: found.minOrderbookDepth ? parseFloat(found.minOrderbookDepth) : undefined,
|
||||
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined
|
||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
||||
configName: found.configName || '',
|
||||
pushFailedOrders: found.pushFailedOrders ?? false
|
||||
})
|
||||
} else {
|
||||
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
|
||||
@@ -122,7 +124,9 @@ const CopyTradingEdit: 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() || undefined,
|
||||
pushFailedOrders: values.pushFailedOrders
|
||||
}
|
||||
|
||||
const response = await apiService.copyTrading.update(request)
|
||||
@@ -172,6 +176,21 @@ const CopyTradingEdit: React.FC = () => {
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
{/* 基础信息(只读) */}
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.configName') || '配置名'}
|
||||
name="configName"
|
||||
rules={[
|
||||
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
|
||||
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
|
||||
]}
|
||||
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
|
||||
maxLength={255}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
|
||||
name="accountId"
|
||||
@@ -433,7 +452,9 @@ const CopyTradingEdit: React.FC = () => {
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* 跟单卖出 - 表单最底部 */}
|
||||
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
|
||||
|
||||
{/* 跟单卖出 */}
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
|
||||
name="supportSell"
|
||||
@@ -443,6 +464,16 @@ const CopyTradingEdit: React.FC = () => {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送失败订单 */}
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
|
||||
name="pushFailedOrders"
|
||||
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -146,6 +146,16 @@ const CopyTradingList: React.FC = () => {
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('copyTradingList.configName') || '配置名',
|
||||
key: 'configName',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: CopyTrading) => (
|
||||
<div style={{ fontSize: isMobile ? 13 : 14, fontWeight: 500 }}>
|
||||
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingList.wallet') || '钱包',
|
||||
key: 'account',
|
||||
@@ -454,10 +464,17 @@ const CopyTradingList: React.FC = () => {
|
||||
{/* 基本信息 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#1890ff'
|
||||
}}>
|
||||
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px',
|
||||
color: '#666'
|
||||
}}>
|
||||
{record.copyMode === 'RATIO'
|
||||
? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Badge, Spin, Row, Col } from 'antd'
|
||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -35,18 +36,23 @@ interface ApiHealthStatus {
|
||||
}
|
||||
|
||||
const SystemSettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [form] = Form.useForm()
|
||||
const [autoRedeemForm] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [checkResult, setCheckResult] = useState<ProxyCheckResponse | null>(null)
|
||||
const [currentConfig, setCurrentConfig] = useState<ProxyConfig | null>(null)
|
||||
const [apiHealthStatus, setApiHealthStatus] = useState<ApiHealthStatus[]>([])
|
||||
const [checkingApiHealth, setCheckingApiHealth] = useState(false)
|
||||
const [autoRedeemLoading, setAutoRedeemLoading] = useState(false)
|
||||
const [builderApiKeyConfigured, setBuilderApiKeyConfigured] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
checkApiHealth()
|
||||
fetchSystemConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
@@ -161,6 +167,38 @@ const SystemSettings: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSystemConfig = async () => {
|
||||
try {
|
||||
const response = await apiService.systemConfig.get()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const config = response.data.data
|
||||
setBuilderApiKeyConfigured(config.builderApiKeyConfigured)
|
||||
autoRedeemForm.setFieldsValue({
|
||||
autoRedeem: config.autoRedeem
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoRedeemSubmit = async (values: { autoRedeem: boolean }) => {
|
||||
setAutoRedeemLoading(true)
|
||||
try {
|
||||
const response = await apiService.systemConfig.updateAutoRedeem({ enabled: values.autoRedeem })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('systemSettings.autoRedeem.saveSuccess') || '自动赎回配置已更新')
|
||||
fetchSystemConfig()
|
||||
} else {
|
||||
message.error(response.data.msg || t('systemSettings.autoRedeem.saveFailed') || '更新自动赎回配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('systemSettings.autoRedeem.saveFailed') || '更新自动赎回配置失败')
|
||||
} finally {
|
||||
setAutoRedeemLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
@@ -389,6 +427,65 @@ const SystemSettings: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<SettingOutlined />
|
||||
<span>{t('systemSettings.autoRedeem.title') || '自动赎回配置'}</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
<Form
|
||||
form={autoRedeemForm}
|
||||
layout="vertical"
|
||||
onFinish={handleAutoRedeemSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('systemSettings.autoRedeem.label') || '自动赎回'}
|
||||
name="autoRedeem"
|
||||
tooltip={t('systemSettings.autoRedeem.tooltip') || '开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效'}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{!builderApiKeyConfigured && (
|
||||
<Alert
|
||||
message={t('systemSettings.autoRedeem.builderApiKeyNotConfigured') || 'Builder API Key 未配置'}
|
||||
description={
|
||||
<span>
|
||||
{t('systemSettings.autoRedeem.builderApiKeyNotConfiguredDesc') || '自动赎回功能需要配置 Builder API Key 才能生效。'}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => window.location.href = '/system-settings/builder-api-key'}
|
||||
style={{ padding: 0, marginLeft: '8px' }}
|
||||
>
|
||||
{t('systemSettings.autoRedeem.goToConfigure') || '前往配置'}
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={autoRedeemLoading}
|
||||
>
|
||||
{t('common.save') || '保存配置'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -594,7 +594,25 @@ export const apiService = {
|
||||
* 更新 Builder API Key 配置
|
||||
*/
|
||||
updateBuilderApiKey: (data: import('../types').BuilderApiKeyUpdateRequest) =>
|
||||
apiClient.post<ApiResponse<import('../types').SystemConfig>>('/system/config/builder-api-key/update', data)
|
||||
apiClient.post<ApiResponse<import('../types').SystemConfig>>('/system/config/builder-api-key/update', data),
|
||||
|
||||
/**
|
||||
* 检查 Builder API Key 是否已配置
|
||||
*/
|
||||
checkBuilderApiKey: () =>
|
||||
apiClient.post<ApiResponse<{ configured: boolean }>>('/system/config/builder-api-key/check', {}),
|
||||
|
||||
/**
|
||||
* 更新自动赎回配置
|
||||
*/
|
||||
updateAutoRedeem: (data: { enabled: boolean }) =>
|
||||
apiClient.post<ApiResponse<import('../types').SystemConfig>>('/system/config/auto-redeem/update', data),
|
||||
|
||||
/**
|
||||
* 获取自动赎回状态
|
||||
*/
|
||||
getAutoRedeemStatus: () =>
|
||||
apiClient.post<ApiResponse<{ enabled: boolean }>>('/system/config/auto-redeem/status', {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 // 自动赎回(系统级别配置,默认开启)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user