diff --git a/backend/IMPLEMENTATION.md b/backend/IMPLEMENTATION.md deleted file mode 100644 index ea22dd0..0000000 --- a/backend/IMPLEMENTATION.md +++ /dev/null @@ -1,239 +0,0 @@ -# 后端实现总结 - -## 已完成的工作 - -### 1. 项目基础结构 ✅ - -- ✅ 创建了完整的后端项目目录结构 -- ✅ 配置了 `application.properties` 配置文件 -- ✅ 创建了 Spring Boot 主应用类 -- ✅ 配置了 Gradle 构建文件 - -### 2. 工具类 ✅ - -从 `/Users/wrbug/hype-quant/quant/src/main/kotlin/com/hypequant/util` 拷贝并适配了以下工具类: - -- ✅ `SafeConvertExt.kt` - 安全类型转换扩展函数 -- ✅ `MathExt.kt` - BigDecimal 数学运算扩展函数 -- ✅ `SystemExt.kt` - 系统环境变量工具 -- ✅ `OkHttpExt.kt` - OkHttp 客户端扩展函数 -- ✅ `CategoryValidator.kt` - 分类验证工具类(新增) - -### 3. Polymarket API 封装 ✅ - -#### 3.1 CLOB API ✅ - -- ✅ 定义了 `PolymarketClobApi` 接口 -- ✅ 实现了 `PolymarketClobService` 服务封装 -- ✅ 支持的功能: - - 获取订单簿 - - 获取价格信息 - - 获取中间价 - - 创建订单 - - 获取活跃订单 - - 取消订单 - - 获取交易记录 - -#### 3.2 Gamma API ✅ - -- ✅ 定义了 `PolymarketGammaApi` 接口 -- ✅ 实现了 `PolymarketGammaService` 服务封装 -- ✅ 支持的功能: - - 获取市场列表(带分类验证) - - 获取市场详情 - - 搜索市场(带分类验证) - - 获取事件列表(带分类验证) - - 获取体育市场 - - 获取加密货币市场 - -### 4. WebSocket 转发服务 ✅ - -- ✅ 实现了 `PolymarketWebSocketHandler` - WebSocket 处理器 -- ✅ 实现了 `PolymarketWebSocketClient` - Polymarket RTDS 客户端 -- ✅ 配置了 `WebSocketConfig` - WebSocket 配置 -- ✅ 支持双向消息转发: - - 前端 → 后端 → Polymarket RTDS - - Polymarket RTDS → 后端 → 前端 - -### 5. 统一响应格式 ✅ - -- ✅ 创建了 `ApiResponse` 统一响应格式 -- ✅ 提供了便捷的响应创建方法: - - `success()` - 成功响应 - - `error()` - 错误响应 - - `paramError()` - 参数错误 - - `authError()` - 认证错误 - - `notFound()` - 资源不存在 - - `businessError()` - 业务逻辑错误 - - `serverError()` - 服务器错误 - -### 6. Controller 实现 ✅ - -- ✅ 实现了 `MarketController` - 市场相关接口 -- ✅ 提供的接口: - - `POST /api/markets/list` - 获取市场列表 - - `POST /api/markets/detail` - 获取市场详情 - - `POST /api/markets/search` - 搜索市场 - - `POST /api/markets/sports` - 获取体育市场 - - `POST /api/markets/crypto` - 获取加密货币市场 - -### 7. 配置类 ✅ - -- ✅ `RetrofitConfig` - Retrofit 和 API 客户端配置 -- ✅ `WebSocketConfig` - WebSocket 配置 - -## 项目结构 - -``` -backend/ -├── src/main/kotlin/com/wrbug/polymarketbot/ -│ ├── api/ # API 接口定义 -│ │ ├── PolymarketClobApi.kt -│ │ └── PolymarketGammaApi.kt -│ ├── config/ # 配置类 -│ │ ├── RetrofitConfig.kt -│ │ └── WebSocketConfig.kt -│ ├── controller/ # 控制器 -│ │ └── MarketController.kt -│ ├── dto/ # 数据传输对象 -│ │ └── ApiResponse.kt -│ ├── service/ # 服务层 -│ │ ├── PolymarketClobService.kt -│ │ └── PolymarketGammaService.kt -│ ├── util/ # 工具类 -│ │ ├── SafeConvertExt.kt -│ │ ├── MathExt.kt -│ │ ├── SystemExt.kt -│ │ ├── OkHttpExt.kt -│ │ └── CategoryValidator.kt -│ ├── websocket/ # WebSocket 处理 -│ │ ├── PolymarketWebSocketHandler.kt -│ │ └── PolymarketWebSocketClient.kt -│ └── PolymarketBotApplication.kt # 主应用类 -├── src/main/resources/ -│ └── application.properties # 配置文件 -├── build.gradle.kts # Gradle 构建配置 -└── settings.gradle.kts # Gradle 设置 -``` - -## 关键特性 - -### 1. 分类验证 - -所有涉及分类的接口都会验证分类参数,仅支持 `sports` 和 `crypto`: - -```kotlin -// 自动验证分类 -CategoryValidator.validate(category) -``` - -### 2. 统一错误处理 - -所有接口统一返回 `ApiResponse` 格式,包含: -- `code`: 错误码 -- `data`: 响应数据 -- `msg`: 错误消息 - -### 3. WebSocket 转发 - -前端通过 `ws://localhost:8000/ws/polymarket` 连接,后端自动转发到 Polymarket RTDS。 - -### 4. 异步支持 - -所有 API 调用使用 Kotlin Coroutines 的 `suspend` 函数,Controller 层使用 `runBlocking` 调用。 - -## 依赖说明 - -主要依赖: -- Spring Boot 3.2.0 -- Kotlin 1.9.20 -- Retrofit 2.9.0 -- OkHttp 4.12.0 -- Java-WebSocket 1.5.4 -- MySQL Connector 8.2.0 - -## 配置说明 - -### 环境变量 - -- `DB_USERNAME`: 数据库用户名 -- `DB_PASSWORD`: 数据库密码 -- `SERVER_PORT`: 服务器端口(默认 8000) -- `POLYMARKET_API_KEY`: Polymarket API Key(可选) - -### application.properties - -```properties -# Polymarket API 配置 -polymarket.clob.base-url=https://clob.polymarket.com -polymarket.gamma.base-url=https://gamma-api.polymarket.com -polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com -polymarket.api-key=${POLYMARKET_API_KEY:} -``` - -## 使用示例 - -### 1. 获取市场列表 - -```bash -curl -X POST http://localhost:8000/api/markets/list \ - -H "Content-Type: application/json" \ - -d '{ - "category": "sports", - "active": true, - "limit": 20 - }' -``` - -### 2. WebSocket 连接 - -```javascript -const ws = new WebSocket('ws://localhost:8000/ws/polymarket'); - -ws.onopen = () => { - // 订阅市场数据 - ws.send(JSON.stringify({ - type: 'subscribe', - channel: 'market', - market: 'market_id' - })); -}; - -ws.onmessage = (event) => { - console.log('收到消息:', event.data); -}; -``` - -## 下一步工作 - -1. **数据库实体和 Repository** - - 创建 Market、Order、Trade 等实体类 - - 实现对应的 Repository 接口 - - 创建 Flyway 迁移脚本 - -2. **数据同步服务** - - 实现市场数据同步任务 - - 实现价格更新任务 - - 实现订单状态同步 - -3. **订单管理 Controller** - - 实现订单创建接口 - - 实现订单查询接口 - - 实现订单取消接口 - -4. **测试** - - 单元测试 - - 集成测试 - - API 测试 - -5. **文档** - - API 文档 - - 部署文档 - -## 注意事项 - -1. **分类限制**: 严格限制只支持 `sports` 和 `crypto` 两个分类 -2. **WebSocket**: 生产环境需要配置正确的 CORS 策略 -3. **API Key**: 某些接口需要配置 Polymarket API Key -4. **错误处理**: 所有异常都会被捕获并返回统一格式的错误响应 - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt index 80500a4..0f9da36 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt @@ -424,12 +424,12 @@ class AccountController( ) } else { ResponseEntity.ok( - ApiResponse.error( - ErrorCode.BUSINESS_ERROR, - e.message, - messageSource - ) - ) + ApiResponse.error( + ErrorCode.BUSINESS_ERROR, + e.message, + messageSource + ) + ) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index a7cb219..83acb9b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -36,7 +36,7 @@ data class SystemConfigDto( val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置 val builderSecretConfigured: Boolean, // Builder Secret 是否已配置 val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置 - val autoRedeem: Boolean = true // 自动赎回(系统级别配置,默认开启) + val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启) ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt index a7761dd..a0794a0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt @@ -6,7 +6,9 @@ package com.wrbug.polymarketbot.dto data class LeaderAddRequest( val leaderAddress: String, val leaderName: String? = null, - val category: String? = null // sports 或 crypto + val category: String? = null, // sports 或 crypto + val remark: String? = null, // Leader 备注(可选) + val website: String? = null // Leader 网站(可选) ) /** @@ -15,7 +17,9 @@ data class LeaderAddRequest( data class LeaderUpdateRequest( val leaderId: Long, val leaderName: String? = null, - val category: String? = null + val category: String? = null, + val remark: String? = null, // Leader 备注(可选) + val website: String? = null // Leader 网站(可选) ) /** @@ -40,6 +44,8 @@ data class LeaderDto( val leaderAddress: String, val leaderName: String?, val category: String?, + val remark: String? = null, // Leader 备注(可选) + val website: String? = null, // Leader 网站(可选) val copyTradingCount: Long = 0, // 跟单关系数量 val totalOrders: Long? = null, // 总订单数(可选) val totalPnl: String? = null, // 总盈亏(可选) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt index 0f3e7ed..2edb468 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt @@ -22,6 +22,12 @@ data class Leader( @Column(name = "category", length = 20) val category: String? = null, // sports 或 crypto,null 表示不筛选 + @Column(name = "remark", columnDefinition = "TEXT") + val remark: String? = null, // Leader 备注(可选) + + @Column(name = "website", length = 500) + val website: String? = null, // Leader 网站(可选) + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt index 37e84de..32d3670 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt @@ -49,10 +49,19 @@ class LeaderService( } // 5. 创建 Leader + // 如果 website 为空,自动设置为 polymarket profile 页 + val website = if (request.website.isNullOrBlank()) { + "https://polymarket.com/profile/${request.leaderAddress}" + } else { + request.website + } + val leader = Leader( leaderAddress = request.leaderAddress, - leaderName = request.leaderName, - category = request.category + leaderName = request.leaderName?.takeIf { it.isNotBlank() }, + category = request.category, + remark = request.remark?.takeIf { it.isNotBlank() }, + website = website ) val saved = leaderRepository.save(leader) @@ -78,9 +87,19 @@ class LeaderService( CategoryValidator.validate(request.category) } + // 处理更新逻辑:如果请求中的字段为 null 或空字符串,都设置为 null + // 如果 website 为空,自动设置为 polymarket profile 页 + val website = if (request.website.isNullOrBlank()) { + "https://polymarket.com/profile/${leader.leaderAddress}" + } else { + request.website + } + val updated = leader.copy( - leaderName = request.leaderName ?: leader.leaderName, - category = request.category ?: leader.category, + leaderName = request.leaderName?.takeIf { it.isNotBlank() }, + category = request.category, + remark = request.remark?.takeIf { it.isNotBlank() }, + website = website, updatedAt = System.currentTimeMillis() ) @@ -175,6 +194,8 @@ class LeaderService( leaderAddress = leader.leaderAddress, leaderName = leader.leaderName, category = leader.category, + remark = leader.remark, + website = leader.website, copyTradingCount = copyTradingCount, createdAt = leader.createdAt, updatedAt = leader.updatedAt diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt index 4fdf549..f170dbe 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionCheckService.kt @@ -3,13 +3,19 @@ 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.entity.SellMatchDetail +import com.wrbug.polymarketbot.entity.SellMatchRecord import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository import com.wrbug.polymarketbot.repository.CopyTradingRepository +import com.wrbug.polymarketbot.repository.SellMatchDetailRepository +import com.wrbug.polymarketbot.repository.SellMatchRecordRepository import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.multi import kotlinx.coroutines.* import org.slf4j.LoggerFactory import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy import org.springframework.context.MessageSource import org.springframework.context.i18n.LocaleContextHolder import org.springframework.stereotype.Service @@ -19,12 +25,16 @@ import java.util.concurrent.ConcurrentHashMap /** * 仓位检查服务 * 负责检查待赎回仓位和未卖出订单,并执行相应的处理逻辑 + * 订阅 PositionPollingService 的事件,处理仓位检查逻辑 */ @Service class PositionCheckService( + private val positionPollingService: PositionPollingService, private val accountService: AccountService, private val copyTradingRepository: CopyTradingRepository, private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val sellMatchRecordRepository: SellMatchRecordRepository, + private val sellMatchDetailRepository: SellMatchDetailRepository, private val systemConfigService: SystemConfigService, private val relayClientService: RelayClientService, private val telegramNotificationService: TelegramNotificationService?, @@ -34,8 +44,9 @@ class PositionCheckService( private val logger = LoggerFactory.getLogger(PositionCheckService::class.java) - // 协程作用域,用于缓存清理任务 + // 协程作用域,用于订阅事件和缓存清理任务 private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var subscriptionJob: Job? = null // 记录已发送通知的仓位(避免重复推送) private val notifiedRedeemablePositions = ConcurrentHashMap() // "accountId_marketId_outcomeIndex" -> lastNotificationTime @@ -43,14 +54,60 @@ class PositionCheckService( // 记录已发送提示的配置(避免重复推送) private val notifiedConfigs = ConcurrentHashMap() // accountId/copyTradingId -> lastNotificationTime + // 同步锁,确保订阅任务的启动和停止是线程安全的 + private val lock = Any() + /** - * 初始化服务(启动缓存清理任务) + * 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务) */ @PostConstruct fun init() { + logger.info("PositionCheckService 初始化,订阅仓位轮训事件") + startSubscription() startCacheCleanup() } + /** + * 清理资源 + */ + @PreDestroy + fun destroy() { + synchronized(lock) { + subscriptionJob?.cancel() + subscriptionJob = null + } + scope.cancel() + } + + /** + * 启动订阅任务(订阅 PositionPollingService 的事件) + */ + private fun startSubscription() { + synchronized(lock) { + // 如果已经有订阅任务在运行,先取消 + subscriptionJob?.cancel() + + // 启动新的订阅任务(使用专门的线程,避免阻塞) + subscriptionJob = scope.launch(Dispatchers.IO) { + try { + // 订阅仓位轮训事件 + positionPollingService.subscribe { positions -> + // 在协程中处理仓位检查逻辑,避免阻塞 + scope.launch(Dispatchers.IO) { + try { + checkPositions(positions.currentPositions) + } catch (e: Exception) { + logger.error("处理仓位检查事件失败: ${e.message}", e) + } + } + } + } catch (e: Exception) { + logger.error("订阅仓位轮训事件失败: ${e.message}", e) + } + } + } + } + /** * 启动缓存清理任务(定期清理过期的通知记录) */ @@ -118,16 +175,55 @@ class PositionCheckService( /** * 逻辑1:处理待赎回仓位 - * 如果有待赎回的仓位,检查是否开启了自动赎回,是否有相同仓位的订单(未卖出的订单) - * 如果有的话,在仓位赎回成功后以该订单卖出逻辑更新所有订单状态(未卖出) - * 如果未开启自动赎回,则发送tg通知,并且记录(内存缓存)该仓位,避免重复发送 + * 按照以下逻辑处理: + * 1. 无待赎回仓位:跳过 + * 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件 + * 3. (已配置) && 有待赎回的仓位:处理订单逻辑 */ private suspend fun checkRedeemablePositions(redeemablePositions: List) { try { + // 1. 无待赎回仓位:跳过 + if (redeemablePositions.isEmpty()) { + return + } + // 检查系统级别的自动赎回配置 val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled() + val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured() - // 按账户分组 + // 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件 + if (!autoRedeemEnabled || !apiKeyConfigured) { + // 按账户分组发送通知 + val positionsByAccount = redeemablePositions.groupBy { it.accountId } + + for ((accountId, positions) in positionsByAccount) { + for (position in positions) { + val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}" + // 检查是否在最近2小时内已发送过提示(避免频繁推送) + val lastNotification = notifiedRedeemablePositions[positionKey] + val now = System.currentTimeMillis() + if (lastNotification == null || (now - lastNotification) >= 7200000) { // 2小时 + if (!autoRedeemEnabled) { + // 自动赎回未开启:直接发送通知,不需要查找跟单配置 + checkAndNotifyAutoRedeemDisabled(accountId, listOf(position)) + } else { + // API Key 未配置:需要查找跟单配置来发送通知 + val copyTradings = copyTradingRepository.findByAccountId(accountId) + .filter { it.enabled } + for (copyTrading in copyTradings) { + checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position)) + } + } + notifiedRedeemablePositions[positionKey] = now + } + } + } + return // 未配置时直接返回,不进行后续处理 + } + + // 3. (已配置) && 有待赎回的仓位:处理订单逻辑 + // 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理 + // 先执行赎回,赎回成功后再查找订单并更新订单状态 val positionsByAccount = redeemablePositions.groupBy { it.accountId } for ((accountId, positions) in positionsByAccount) { @@ -139,90 +235,52 @@ class PositionCheckService( continue } - // 收集所有有未卖出订单的仓位,按账户分组一次性处理 - val positionsWithOrders = mutableListOf>>() - val positionsWithoutOrders = mutableListOf() - - for (position in positions) { - // 查找相同仓位的未卖出订单(remaining_quantity > 0) - val unmatchedOrders = mutableListOf() - for (copyTrading in copyTradings) { - if (position.outcomeIndex != null) { - val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex( - copyTrading.id!!, - position.marketId, - position.outcomeIndex - ) - unmatchedOrders.addAll(orders) - } - } - - if (unmatchedOrders.isNotEmpty()) { - positionsWithOrders.add(Pair(position, unmatchedOrders)) - } else { - positionsWithoutOrders.add(position) - } - } - - // 处理有未卖出订单的仓位 - if (positionsWithOrders.isNotEmpty()) { - if (autoRedeemEnabled && relayClientService.isBuilderApiKeyConfigured()) { - // 开启自动赎回且已配置 API Key,执行自动赎回(一次性赎回该账户的所有可赎回仓位) - val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest( - positions = positionsWithOrders.map { (position, _) -> - com.wrbug.polymarketbot.dto.AccountRedeemPositionItem( - accountId = accountId, - marketId = position.marketId, - outcomeIndex = position.outcomeIndex ?: 0, - side = position.side - ) - } + // 先执行赎回(不查找订单) + val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest( + positions = positions.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=${positions.size}, totalValue=${response.totalRedeemedValue}") - 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)) + // 赎回成功后,再查找订单并更新订单状态 + for (position in positions) { + // 查找相同仓位的未卖出订单(remaining_quantity > 0) + val unmatchedOrders = mutableListOf() + for (copyTrading in copyTradings) { + if (position.outcomeIndex != null) { + val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex( + copyTrading.id!!, + position.marketId, + position.outcomeIndex + ) + unmatchedOrders.addAll(orders) + } + } + + // 如果有未卖出订单,更新订单状态 + if (unmatchedOrders.isNotEmpty()) { + // 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId) + val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId + if (copyTradingId != null) { + updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId) } } - // 记录已发送通知的仓位(避免重复发送) - notifiedRedeemablePositions[positionKey] = System.currentTimeMillis() } + }, + onFailure = { e -> + logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e) } - } - - // 处理没有未卖出订单的仓位(如果未开启自动赎回,发送通知) - 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) @@ -274,17 +332,15 @@ class PositionCheckService( if (position == null) { // 仓位不存在,更新所有订单状态为已卖出 val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex) - updateOrdersAsSold(orders, currentPrice) + updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex) } else { - // 有仓位,检查仓位数量是否小于所有未卖出订单数量总和 + // 有仓位,按订单下单顺序(FIFO)更新状态 + // 如果仓位数量 >= 订单数量总和,所有订单完全成交 + // 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交 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) - } + val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex) + updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex) } } } @@ -320,11 +376,12 @@ class PositionCheckService( */ private suspend fun updateOrdersAsSoldAfterRedeem( orders: List, - position: AccountPositionDto + position: AccountPositionDto, + copyTradingId: Long ) { try { val currentPrice = getCurrentMarketPrice(position.marketId, position.outcomeIndex ?: 0) - updateOrdersAsSold(orders, currentPrice) + updateOrdersAsSold(orders, currentPrice, copyTradingId, position.marketId, position.outcomeIndex ?: 0) } catch (e: Exception) { logger.error("更新订单状态为已卖出失败: ${e.message}", e) } @@ -332,21 +389,85 @@ class PositionCheckService( /** * 更新订单状态为已卖出(使用当前最新价) + * 同时创建卖出记录和匹配明细,用于统计 */ private suspend fun updateOrdersAsSold( orders: List, - sellPrice: BigDecimal + sellPrice: BigDecimal, + copyTradingId: Long, + marketId: String, + outcomeIndex: Int ) { + if (orders.isEmpty()) { + return + } + try { + // 计算总匹配数量和总盈亏 + var totalMatchedQuantity = BigDecimal.ZERO + var totalRealizedPnl = BigDecimal.ZERO + val matchDetails = mutableListOf() + for (order in orders) { + val remainingQty = order.remainingQuantity.toSafeBigDecimal() + if (remainingQty <= BigDecimal.ZERO) { + continue + } + + // 计算盈亏 + val buyPrice = order.price.toSafeBigDecimal() + val realizedPnl = sellPrice.subtract(buyPrice).multi(remainingQty) + + // 创建匹配明细(稍后保存) + val detail = SellMatchDetail( + matchRecordId = 0, // 稍后设置 + trackingId = order.id!!, + buyOrderId = order.buyOrderId, + matchedQuantity = remainingQty, + buyPrice = buyPrice, + sellPrice = sellPrice, + realizedPnl = realizedPnl + ) + matchDetails.add(detail) + + totalMatchedQuantity = totalMatchedQuantity.add(remainingQty) + totalRealizedPnl = totalRealizedPnl.add(realizedPnl) + // 更新订单状态:将剩余数量标记为已匹配 - order.matchedQuantity = order.matchedQuantity.add(order.remainingQuantity) + order.matchedQuantity = order.matchedQuantity.add(remainingQty) order.remainingQuantity = BigDecimal.ZERO order.status = "fully_matched" order.updatedAt = System.currentTimeMillis() copyOrderTrackingRepository.save(order) + } + + // 如果有匹配的订单,创建卖出记录 + if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) { + val timestamp = System.currentTimeMillis() + val sellOrderId = "AUTO_${timestamp}_${copyTradingId}" + val leaderSellTradeId = "AUTO_${timestamp}" - logger.info("更新订单状态为已卖出: orderId=${order.buyOrderId}, marketId=${order.marketId}, sellPrice=$sellPrice") + val matchRecord = SellMatchRecord( + copyTradingId = copyTradingId, + sellOrderId = sellOrderId, + leaderSellTradeId = leaderSellTradeId, + marketId = marketId, + side = outcomeIndex.toString(), // 使用outcomeIndex作为side + outcomeIndex = outcomeIndex, + totalMatchedQuantity = totalMatchedQuantity, + sellPrice = sellPrice, + totalRealizedPnl = totalRealizedPnl + ) + + val savedRecord = sellMatchRecordRepository.save(matchRecord) + + // 保存匹配明细 + for (detail in matchDetails) { + val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) + sellMatchDetailRepository.save(savedDetail) + } + + logger.info("创建自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl") } } catch (e: Exception) { logger.error("更新订单状态为已卖出异常: ${e.message}", e) @@ -356,15 +477,26 @@ class PositionCheckService( /** * 按 FIFO 顺序更新订单状态为已卖出 * 仓位数量小于订单数量总和时,按订单下单顺序更新 + * 同时创建卖出记录和匹配明细,用于统计 */ private suspend fun updateOrdersAsSoldByFIFO( orders: List, availableQuantity: BigDecimal, - sellPrice: BigDecimal + sellPrice: BigDecimal, + copyTradingId: Long, + marketId: String, + outcomeIndex: Int ) { + if (orders.isEmpty()) { + return + } + try { // 订单已经按 createdAt ASC 排序(FIFO) var remaining = availableQuantity + var totalMatchedQuantity = BigDecimal.ZERO + var totalRealizedPnl = BigDecimal.ZERO + val matchDetails = mutableListOf() for (order in orders) { if (remaining <= BigDecimal.ZERO) { @@ -375,6 +507,25 @@ class PositionCheckService( val toMatch = minOf(orderRemaining, remaining) if (toMatch > BigDecimal.ZERO) { + // 计算盈亏 + val buyPrice = order.price.toSafeBigDecimal() + val realizedPnl = sellPrice.subtract(buyPrice).multi(toMatch) + + // 创建匹配明细(稍后保存) + val detail = SellMatchDetail( + matchRecordId = 0, // 稍后设置 + trackingId = order.id!!, + buyOrderId = order.buyOrderId, + matchedQuantity = toMatch, + buyPrice = buyPrice, + sellPrice = sellPrice, + realizedPnl = realizedPnl + ) + matchDetails.add(detail) + + totalMatchedQuantity = totalMatchedQuantity.add(toMatch) + totalRealizedPnl = totalRealizedPnl.add(realizedPnl) + order.matchedQuantity = order.matchedQuantity.add(toMatch) order.remainingQuantity = order.remainingQuantity.subtract(toMatch) @@ -393,6 +544,35 @@ class PositionCheckService( logger.info("按 FIFO 更新订单状态: orderId=${order.buyOrderId}, matched=$toMatch, remaining=${order.remainingQuantity}") } } + + // 如果有匹配的订单,创建卖出记录 + if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) { + val timestamp = System.currentTimeMillis() + val sellOrderId = "AUTO_FIFO_${timestamp}_${copyTradingId}" + val leaderSellTradeId = "AUTO_FIFO_${timestamp}" + + val matchRecord = SellMatchRecord( + copyTradingId = copyTradingId, + sellOrderId = sellOrderId, + leaderSellTradeId = leaderSellTradeId, + marketId = marketId, + side = outcomeIndex.toString(), // 使用outcomeIndex作为side + outcomeIndex = outcomeIndex, + totalMatchedQuantity = totalMatchedQuantity, + sellPrice = sellPrice, + totalRealizedPnl = totalRealizedPnl + ) + + val savedRecord = sellMatchRecordRepository.save(matchRecord) + + // 保存匹配明细 + for (detail in matchDetails) { + val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) + sellMatchDetailRepository.save(savedDetail) + } + + logger.info("创建FIFO自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl") + } } catch (e: Exception) { logger.error("按 FIFO 更新订单状态异常: ${e.message}", e) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPollingService.kt new file mode 100644 index 0000000..82ac97e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPollingService.kt @@ -0,0 +1,156 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.PositionListResponse +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +/** + * 仓位轮训服务 + * 独立负责轮训仓位数据,通过事件机制分发给订阅者 + * 使用专门的线程处理事件分发,避免阻塞轮训 + * 提供丢弃机制:如果消费者处理慢,只保留最新的一份数据 + */ +@Service +class PositionPollingService( + private val accountService: AccountService +) { + + private val logger = LoggerFactory.getLogger(PositionPollingService::class.java) + + @Value("\${position.polling.interval:2000}") + private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒 + + // 订阅者列表(支持多个订阅者) + private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>() + + // 最新仓位数据(用于丢弃机制) + @Volatile + private var latestPositions: PositionListResponse? = null + + // 协程作用域和任务 + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var pollingJob: Job? = null + + // 事件分发协程(使用专门的线程,避免阻塞轮训) + private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // 同步锁,确保轮询任务的启动和停止是线程安全的 + private val lock = Any() + + /** + * 初始化服务(后端启动时直接启动轮训) + */ + @PostConstruct + fun init() { + logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms") + startPolling() + } + + /** + * 清理资源 + */ + @PreDestroy + fun destroy() { + synchronized(lock) { + pollingJob?.cancel() + pollingJob = null + } + subscribers.clear() + scope.cancel() + eventDispatcherScope.cancel() + } + + /** + * 订阅仓位事件 + * @param callback 回调函数,接收最新的仓位数据 + */ + fun subscribe(callback: (PositionListResponse) -> Unit) { + synchronized(lock) { + subscribers.add(callback) + // 如果有最新数据,立即发送给新订阅者 + latestPositions?.let { callback(it) } + } + } + + /** + * 取消订阅仓位事件 + */ + fun unsubscribe(callback: (PositionListResponse) -> Unit) { + synchronized(lock) { + subscribers.remove(callback) + } + } + + /** + * 启动轮训任务 + */ + private fun startPolling() { + synchronized(lock) { + // 如果已经有轮训任务在运行,先取消 + pollingJob?.cancel() + + // 启动新的轮训任务 + pollingJob = scope.launch { + while (isActive) { + try { + pollPositions() + } catch (e: Exception) { + logger.error("轮训仓位数据失败: ${e.message}", e) + } + delay(pollingInterval) + } + } + } + } + + /** + * 轮训仓位数据并发布事件 + * 使用专门的线程分发事件,避免阻塞轮训 + * 实现丢弃机制:只保留最新的一份数据 + */ + private suspend fun pollPositions() { + try { + val result = accountService.getAllPositions() + if (result.isSuccess) { + val positions = result.getOrNull() + if (positions != null) { + // 更新最新数据(丢弃旧数据,只保留最新的) + latestPositions = positions + + // 在专门的线程中分发事件,避免阻塞轮训 + eventDispatcherScope.launch { + try { + // 通知所有订阅者(在专门的线程中执行,避免阻塞) + val currentSubscribers = synchronized(lock) { + subscribers.toList() // 复制列表,避免并发修改 + } + + currentSubscribers.forEach { callback -> + try { + callback(positions) + } catch (e: Exception) { + logger.error("通知订阅者失败: ${e.message}", e) + } + } + + logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}") + } catch (e: Exception) { + logger.error("分发仓位数据事件失败: ${e.message}", e) + } + } + } + } else { + logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}") + } + } catch (e: Exception) { + logger.error("轮训仓位数据异常: ${e.message}", e) + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt index eb4a91f..b19cf82 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt @@ -8,25 +8,21 @@ import jakarta.annotation.PostConstruct import jakarta.annotation.PreDestroy import kotlinx.coroutines.* import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.util.concurrent.ConcurrentHashMap /** * 仓位推送服务 - * 轮询仓位接口,比较差异并推送增量更新 + * 订阅 PositionPollingService 的事件,推送给 WebSocket 客户端 */ @Service class PositionPushService( - private val accountService: AccountService, - private val positionCheckService: PositionCheckService + private val positionPollingService: PositionPollingService, + private val accountService: AccountService ) { private val logger = LoggerFactory.getLogger(PositionPushService::class.java) - @Value("\${position.push.polling-interval:3000}") - private var pollingInterval: Long = 3000 // 轮询间隔(毫秒),默认3秒 - // 存储客户端会话和对应的推送回调 private val clientCallbacks = ConcurrentHashMap Unit>() @@ -36,18 +32,18 @@ class PositionPushService( // 协程作用域和任务 private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private var pollingJob: Job? = null + private var subscriptionJob: Job? = null - // 同步锁,确保轮询任务的启动和停止是线程安全的 + // 同步锁,确保订阅任务的启动和停止是线程安全的 private val lock = Any() /** - * 初始化服务(后端启动时直接启动轮询) + * 初始化服务(订阅 PositionPollingService 的事件) */ @PostConstruct fun init() { - logger.info("PositionPushService 初始化,启动仓位轮询任务") - startPolling() + logger.info("PositionPushService 初始化,订阅仓位轮训事件") + startSubscription() } /** @@ -56,8 +52,8 @@ class PositionPushService( @PreDestroy fun destroy() { synchronized(lock) { - pollingJob?.cancel() - pollingJob = null + subscriptionJob?.cancel() + subscriptionJob = null } scope.cancel() } @@ -78,27 +74,23 @@ class PositionPushService( /** * 注册客户端会话(兼容旧接口) - * 轮询任务已在后端启动时启动,这里只需要注册回调 */ fun registerSession(sessionId: String, callback: (PositionPushMessage) -> Unit) { logger.info("注册仓位推送客户端会话: $sessionId") synchronized(lock) { clientCallbacks[sessionId] = callback - // 轮询任务已在后端启动时启动,不需要在这里启动 } } /** * 注销客户端会话(兼容旧接口) - * 轮询任务持续运行,不因客户端断开而停止 */ fun unregisterSession(sessionId: String) { logger.info("注销仓位推送客户端会话: $sessionId") synchronized(lock) { clientCallbacks.remove(sessionId) - // 轮询任务持续运行,不停止 } } @@ -134,179 +126,59 @@ class PositionPushService( } /** - * 启动轮询任务 + * 启动订阅任务(订阅 PositionPollingService 的事件) */ - private fun startPolling() { + private fun startSubscription() { synchronized(lock) { - // 如果已经有轮询任务在运行,先取消 - pollingJob?.cancel() + // 如果已经有订阅任务在运行,先取消 + subscriptionJob?.cancel() - // 启动新的轮询任务 - pollingJob = scope.launch { - while (isActive) { - try { - pollAndPush() - } catch (e: Exception) { - logger.error("轮询仓位数据失败: ${e.message}", e) - } - delay(pollingInterval) - } - } - } - } - - /** - * 停止轮询任务 - */ - private fun stopPolling() { - synchronized(lock) { - pollingJob?.cancel() - pollingJob = null - } - } - - /** - * 轮询仓位数据并推送全量数据 - * 根据文档要求:每次轮训完成后向订阅者发送全量数据 - */ - private suspend fun pollAndPush() { - try { - val result = accountService.getAllPositions() - if (result.isSuccess) { - val positions = result.getOrNull() - if (positions != null) { - // 更新快照 - lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() } - lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() } - - // 向所有订阅者发送全量数据 - if (clientCallbacks.isNotEmpty()) { - val message = PositionPushMessage( - type = PositionPushMessageType.FULL, - timestamp = System.currentTimeMillis(), - currentPositions = positions.currentPositions, - historyPositions = positions.historyPositions - ) - - // 推送给所有连接的客户端 - clientCallbacks.values.forEach { callback -> - try { - callback(message) - } catch (e: Exception) { - logger.error("推送全量数据失败: ${e.message}", e) - } + // 启动新的订阅任务(使用专门的线程,避免阻塞) + subscriptionJob = scope.launch(Dispatchers.IO) { + try { + // 订阅仓位轮训事件 + positionPollingService.subscribe { positions -> + try { + handlePositionUpdate(positions) + } catch (e: Exception) { + logger.error("处理仓位更新事件失败: ${e.message}", e) } } - - // 仓位检查逻辑(复用仓位轮询) - // 处理待赎回仓位和未卖出订单 - positionCheckService.checkPositions(positions.currentPositions) + } catch (e: Exception) { + logger.error("订阅仓位轮训事件失败: ${e.message}", e) } - } else { - logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}") } - } catch (e: Exception) { - logger.error("轮询仓位数据异常: ${e.message}", e) } } /** - * 计算增量更新 - * 返回 null 表示没有变化 + * 处理仓位更新事件 + * 根据文档要求:每次轮训完成后向订阅者发送全量数据 */ - private fun calculateIncremental( - newCurrentPositions: List, - newHistoryPositions: List - ): IncrementalUpdate? { - val newCurrentMap = newCurrentPositions.associateBy { it.getPositionKey() } - val newHistoryMap = newHistoryPositions.associateBy { it.getPositionKey() } + private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) { + // 更新快照 + lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() } + lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() } - // 找出新增或更新的当前仓位 - val updatedCurrentPositions = mutableListOf() - newCurrentMap.forEach { (key, newPos) -> - val oldPos = lastCurrentPositions[key] - if (oldPos == null || hasChanged(oldPos, newPos)) { - updatedCurrentPositions.add(newPos) - } - } - - // 找出新增或更新的历史仓位 - val updatedHistoryPositions = mutableListOf() - newHistoryMap.forEach { (key, newPos) -> - val oldPos = lastHistoryPositions[key] - if (oldPos == null || hasChanged(oldPos, newPos)) { - updatedHistoryPositions.add(newPos) - } - } - - // 找出已删除的仓位(从当前仓位变为历史仓位,或完全删除) - val removedKeys = mutableListOf() - - // 检查上次的当前仓位是否还在当前仓位列表中 - lastCurrentPositions.forEach { (key, _) -> - if (!newCurrentMap.containsKey(key)) { - // 如果不在当前仓位中,检查是否移到了历史仓位 - if (!newHistoryMap.containsKey(key)) { - // 完全删除 - removedKeys.add(key) - } else { - // 从当前仓位移到历史仓位,需要更新历史仓位 - newHistoryMap[key]?.let { updatedHistoryPositions.add(it) } + // 向所有订阅者发送全量数据 + if (clientCallbacks.isNotEmpty()) { + val message = PositionPushMessage( + type = PositionPushMessageType.FULL, + timestamp = System.currentTimeMillis(), + currentPositions = positions.currentPositions, + historyPositions = positions.historyPositions + ) + + // 推送给所有连接的客户端(在专门的线程中执行,避免阻塞) + scope.launch(Dispatchers.IO) { + clientCallbacks.values.forEach { callback -> + try { + callback(message) + } catch (e: Exception) { + logger.error("推送全量数据失败: ${e.message}", e) + } } } } - - // 检查上次的历史仓位是否还在历史仓位列表中 - lastHistoryPositions.forEach { (key, _) -> - if (!newHistoryMap.containsKey(key)) { - // 如果不在历史仓位中,检查是否移到了当前仓位 - if (!newCurrentMap.containsKey(key)) { - // 完全删除 - removedKeys.add(key) - } else { - // 从历史仓位移到当前仓位,需要更新当前仓位 - newCurrentMap[key]?.let { updatedCurrentPositions.add(it) } - } - } - } - - // 如果没有变化,返回 null - if (updatedCurrentPositions.isEmpty() && updatedHistoryPositions.isEmpty() && removedKeys.isEmpty()) { - return null - } - - return IncrementalUpdate( - currentPositions = updatedCurrentPositions, - historyPositions = updatedHistoryPositions, - removedKeys = removedKeys - ) } - - /** - * 检查仓位是否有变化 - * 比较关键字段:数量、价格、价值、盈亏等 - */ - private fun hasChanged(old: AccountPositionDto, new: AccountPositionDto): Boolean { - return old.quantity != new.quantity || - old.avgPrice != new.avgPrice || - old.currentPrice != new.currentPrice || - old.currentValue != new.currentValue || - old.pnl != new.pnl || - old.percentPnl != new.percentPnl || - old.realizedPnl != new.realizedPnl || - old.percentRealizedPnl != new.percentRealizedPnl || - old.redeemable != new.redeemable || - old.mergeable != new.mergeable || - old.isCurrent != new.isCurrent - } - - /** - * 增量更新数据 - */ - private data class IncrementalUpdate( - val currentPositions: List, - val historyPositions: List, - val removedKeys: List - ) } - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt index 7ab082d..0beef4b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt @@ -17,16 +17,16 @@ class SystemConfigService( private val systemConfigRepository: SystemConfigRepository, private val cryptoUtils: CryptoUtils ) { - + private val logger = LoggerFactory.getLogger(SystemConfigService::class.java) - + companion object { 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" } - + /** * 获取系统配置 */ @@ -35,15 +35,15 @@ class SystemConfigService( 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, - autoRedeem = autoRedeem + autoRedeemEnabled = autoRedeem ) } - + /** * 更新 Builder API Key 配置 */ @@ -61,7 +61,7 @@ class SystemConfigService( } ) } - + // 更新 Builder Secret if (request.builderSecret != null) { updateConfigValue( @@ -73,7 +73,7 @@ class SystemConfigService( } ) } - + // 更新 Builder Passphrase if (request.builderPassphrase != null) { updateConfigValue( @@ -85,7 +85,7 @@ class SystemConfigService( } ) } - + // 更新自动赎回配置 if (request.autoRedeem != null) { updateConfigValue( @@ -93,29 +93,29 @@ class SystemConfigService( request.autoRedeem.toString() ) } - + Result.success(getSystemConfig()) } catch (e: Exception) { logger.error("更新系统配置失败", e) Result.failure(e) } } - + /** * 获取配置值(解密) */ fun getBuilderApiKey(): String? { return getConfigValue(CONFIG_KEY_BUILDER_API_KEY)?.let { cryptoUtils.decrypt(it) } } - + fun getBuilderSecret(): String? { return getConfigValue(CONFIG_KEY_BUILDER_SECRET)?.let { cryptoUtils.decrypt(it) } } - + fun getBuilderPassphrase(): String? { return getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)?.let { cryptoUtils.decrypt(it) } } - + /** * 检查 Builder API Key 是否已配置 */ @@ -125,7 +125,7 @@ class SystemConfigService( val passphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE) return apiKey != null && secret != null && passphrase != null } - + /** * 检查自动赎回是否启用 */ @@ -134,10 +134,10 @@ class SystemConfigService( return when (autoRedeemValue?.lowercase()) { "true" -> true "false" -> false - else -> true // 默认开启 + else -> false // 默认开启 } } - + /** * 更新自动赎回配置 */ @@ -151,14 +151,14 @@ class SystemConfigService( Result.failure(e) } } - + /** * 获取配置值(原始值,加密存储) */ private fun getConfigValue(configKey: String): String? { return systemConfigRepository.findByConfigKey(configKey)?.configValue } - + /** * 更新配置值 */ diff --git a/backend/src/main/resources/db/migration/V10__add_leader_remark_and_website.sql b/backend/src/main/resources/db/migration/V10__add_leader_remark_and_website.sql new file mode 100644 index 0000000..f3ddb98 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__add_leader_remark_and_website.sql @@ -0,0 +1,8 @@ +-- ============================================ +-- V10: 添加 Leader 备注和网站字段 +-- ============================================ + +ALTER TABLE copy_trading_leaders +ADD COLUMN remark TEXT NULL COMMENT 'Leader 备注(可选)', +ADD COLUMN website VARCHAR(500) NULL COMMENT 'Leader 网站(可选)'; + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4476d34..46af972 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,11 +31,7 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders' import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' -import LanguageSettings from './pages/LanguageSettings' import ApiHealthStatus from './pages/ApiHealthStatus' -import ProxySettings from './pages/ProxySettings' -import NotificationSettings from './pages/NotificationSettings' -import BuilderApiKeySettings from './pages/BuilderApiKeySettings' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -264,11 +260,7 @@ function App() { } /> } /> } /> - } /> } /> - } /> - } /> - } /> {/* 默认重定向到登录页 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d97f26d..17db5b3 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -17,10 +17,7 @@ import { SettingOutlined, GithubOutlined, TwitterOutlined, - GlobalOutlined, - CheckCircleOutlined, - NotificationOutlined, - KeyOutlined + CheckCircleOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -120,37 +117,17 @@ const Layout: React.FC = ({ children }) => { { key: '/system-settings', icon: , - label: t('menu.systemSettings'), + label: t('menu.systemSettings') || '系统管理', children: [ { key: '/system-settings', icon: , - label: t('menu.systemOverview') || '概览' - }, - { - key: '/system-settings/language', - icon: , - label: t('menu.language') + label: t('menu.systemOverview') || '通用设置' }, { key: '/system-settings/api-health', icon: , - label: t('menu.apiHealth') - }, - { - key: '/system-settings/proxy', - icon: , - label: t('menu.proxy') - }, - { - key: '/system-settings/builder-api-key', - icon: , - label: t('menu.builderApiKey') || 'Builder API Key' - }, - { - key: '/system-settings/notifications', - icon: , - label: t('menu.notifications') + label: t('menu.apiHealth') || 'API健康' } ] }, diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index dac52a5..4494d65 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -305,15 +305,30 @@ "footer": "Please use the above pages for configuration management." }, "systemSettings": { + "title": "General Settings", + "language": { + "title": "Language Settings", + "currentLanguage": "Current Language", + "languageRequired": "Please select a language" + }, + "notification": { + "title": "Notification Settings" + }, + "relayer": { + "title": "Relayer Configuration" + }, + "proxy": { + "title": "Proxy Settings" + }, "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", + "label": "Enable Auto Redeem", + "tooltip": "When enabled, the system will automatically redeem all redeemable positions in all accounts. 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" + "saveSuccess": "Auto redeem configuration saved", + "saveFailed": "Failed to save auto redeem configuration" } }, "builderApiKey": { @@ -394,6 +409,9 @@ "leaderName": "Leader Name", "walletAddress": "Wallet Address", "category": "Category", + "remark": "Remark", + "website": "Website", + "openWebsite": "Open Website", "all": "All", "copyTradingCount": "Copy Trading Count", "copyTradingRelations": "{{count}} copy trading relations", @@ -405,6 +423,52 @@ "deleteConfirm": "Are you sure you want to delete this Leader?", "deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first" }, + "leaderAdd": { + "title": "Add Leader", + "leaderAddress": "Leader Wallet Address", + "leaderAddressRequired": "Please enter Leader wallet address", + "leaderAddressInvalid": "Invalid wallet address format (must be a 42-character address starting with 0x)", + "leaderAddressTooltip": "The wallet address of the leader to be copied. The system will monitor transactions from this address and automatically copy trade", + "leaderName": "Leader Name", + "leaderNameTooltip": "Optional, used to identify the Leader for easier management", + "leaderNamePlaceholder": "Optional, used to identify the Leader", + "remark": "Leader Remark", + "remarkTooltip": "Optional, used to record remarks about the Leader", + "remarkPlaceholder": "Optional, used to record remarks about the Leader", + "website": "Leader Website", + "websiteTooltip": "Optional, Leader's website link", + "websitePlaceholder": "Optional, e.g.: https://example.com", + "websiteInvalid": "Please enter a valid URL address", + "category": "Category Filter", + "categoryTooltip": "Only copy trades of this category. Leave empty to copy all categories (sports or crypto)", + "categoryPlaceholder": "Select category (optional)", + "add": "Add Leader", + "cancel": "Cancel", + "addSuccess": "Leader added successfully", + "addFailed": "Failed to add Leader" + }, + "leaderEdit": { + "title": "Edit Leader", + "leaderName": "Leader Name", + "leaderNameTooltip": "Optional, used to identify the Leader for easier management", + "leaderNamePlaceholder": "Optional, used to identify the Leader", + "remark": "Leader Remark", + "remarkTooltip": "Optional, used to record remarks about the Leader", + "remarkPlaceholder": "Optional, used to record remarks about the Leader", + "website": "Leader Website", + "websiteTooltip": "Optional, Leader's website link", + "websitePlaceholder": "Optional, e.g.: https://example.com", + "websiteInvalid": "Please enter a valid URL address", + "category": "Category Filter", + "categoryTooltip": "Only copy trades of this category. Leave empty to copy all categories (sports or crypto)", + "categoryPlaceholder": "Select category (optional)", + "save": "Save", + "cancel": "Cancel", + "saveSuccess": "Leader updated successfully", + "saveFailed": "Failed to update Leader", + "fetchFailed": "Failed to get Leader details", + "invalidId": "Invalid Leader ID" + }, "userList": { "title": "User Management", "username": "Username", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 5195545..5133870 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -169,7 +169,10 @@ "lastCheck": "最后检查", "healthy": "健康", "unhealthy": "不健康", - "unknown": "未知" + "unknown": "未知", + "normal": "正常", + "notConfigured": "未配置", + "abnormal": "异常" }, "proxySettings": { "title": "代理设置", @@ -217,15 +220,30 @@ "footer": "请使用上述页面进行配置管理。" }, "systemSettings": { + "title": "通用设置", + "language": { + "title": "多语言设置", + "currentLanguage": "当前语言", + "languageRequired": "请选择语言" + }, + "notification": { + "title": "消息推送设置" + }, + "relayer": { + "title": "Relayer 配置" + }, + "proxy": { + "title": "代理设置" + }, "autoRedeem": { "title": "自动赎回配置", - "label": "自动赎回", - "tooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效", + "label": "启用自动赎回", + "tooltip": "开启后,系统将自动赎回所有账户中可赎回的仓位。需要配置 Builder API Key 才能生效。", "builderApiKeyNotConfigured": "Builder API Key 未配置", "builderApiKeyNotConfiguredDesc": "自动赎回功能需要配置 Builder API Key 才能生效。", "goToConfigure": "前往配置", - "saveSuccess": "自动赎回配置已更新", - "saveFailed": "更新自动赎回配置失败" + "saveSuccess": "自动赎回配置已保存", + "saveFailed": "保存自动赎回配置失败" } }, "builderApiKey": { @@ -306,6 +324,9 @@ "leaderName": "Leader 名称", "walletAddress": "钱包地址", "category": "分类", + "remark": "备注", + "website": "网站", + "openWebsite": "打开网页", "all": "全部", "copyTradingCount": "跟单关系数", "copyTradingRelations": "{{count}} 个跟单关系", @@ -317,6 +338,52 @@ "deleteConfirm": "确定要删除这个 Leader 吗?", "deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系" }, + "leaderAdd": { + "title": "添加 Leader", + "leaderAddress": "Leader 钱包地址", + "leaderAddressRequired": "请输入 Leader 钱包地址", + "leaderAddressInvalid": "钱包地址格式不正确(必须是 0x 开头的 42 位地址)", + "leaderAddressTooltip": "被跟单者的钱包地址,系统将监控该地址的交易并自动跟单", + "leaderName": "Leader 名称", + "leaderNameTooltip": "可选,用于标识 Leader,方便管理", + "leaderNamePlaceholder": "可选,用于标识 Leader", + "remark": "Leader 备注", + "remarkTooltip": "可选,用于记录 Leader 的备注信息", + "remarkPlaceholder": "可选,用于记录 Leader 的备注信息", + "website": "Leader 网站", + "websiteTooltip": "可选,Leader 的网站链接", + "websitePlaceholder": "可选,例如:https://example.com", + "websiteInvalid": "请输入有效的 URL 地址", + "category": "分类筛选", + "categoryTooltip": "仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)", + "categoryPlaceholder": "选择分类(可选)", + "add": "添加 Leader", + "cancel": "取消", + "addSuccess": "添加 Leader 成功", + "addFailed": "添加 Leader 失败" + }, + "leaderEdit": { + "title": "编辑 Leader", + "leaderName": "Leader 名称", + "leaderNameTooltip": "可选,用于标识 Leader,方便管理", + "leaderNamePlaceholder": "可选,用于标识 Leader", + "remark": "Leader 备注", + "remarkTooltip": "可选,用于记录 Leader 的备注信息", + "remarkPlaceholder": "可选,用于记录 Leader 的备注信息", + "website": "Leader 网站", + "websiteTooltip": "可选,Leader 的网站链接", + "websitePlaceholder": "可选,例如:https://example.com", + "websiteInvalid": "请输入有效的 URL 地址", + "category": "分类筛选", + "categoryTooltip": "仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto)", + "categoryPlaceholder": "选择分类(可选)", + "save": "保存", + "cancel": "取消", + "saveSuccess": "更新 Leader 成功", + "saveFailed": "更新 Leader 失败", + "fetchFailed": "获取 Leader 详情失败", + "invalidId": "Leader ID 无效" + }, "userList": { "title": "用户管理", "username": "用户名", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index c2d31fa..fb157e7 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -305,15 +305,30 @@ "footer": "請使用上述頁面進行配置管理。" }, "systemSettings": { + "title": "通用設置", + "language": { + "title": "多語言設置", + "currentLanguage": "當前語言", + "languageRequired": "請選擇語言" + }, + "notification": { + "title": "消息推送設置" + }, + "relayer": { + "title": "Relayer 配置" + }, + "proxy": { + "title": "代理設置" + }, "autoRedeem": { "title": "自動贖回配置", - "label": "自動贖回", - "tooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效", + "label": "啟用自動贖回", + "tooltip": "開啟後,系統將自動贖回所有賬戶中可贖回的倉位。需要配置 Builder API Key 才能生效。", "builderApiKeyNotConfigured": "Builder API Key 未配置", "builderApiKeyNotConfiguredDesc": "自動贖回功能需要配置 Builder API Key 才能生效。", "goToConfigure": "前往配置", - "saveSuccess": "自動贖回配置已更新", - "saveFailed": "更新自動贖回配置失敗" + "saveSuccess": "自動贖回配置已保存", + "saveFailed": "保存自動贖回配置失敗" } }, "builderApiKey": { @@ -394,6 +409,9 @@ "leaderName": "Leader 名稱", "walletAddress": "錢包地址", "category": "分類", + "remark": "備註", + "website": "網站", + "openWebsite": "打開網頁", "all": "全部", "copyTradingCount": "跟單關係數", "copyTradingRelations": "{{count}} 個跟單關係", @@ -405,6 +423,52 @@ "deleteConfirm": "確定要刪除這個 Leader 嗎?", "deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係" }, + "leaderAdd": { + "title": "添加 Leader", + "leaderAddress": "Leader 錢包地址", + "leaderAddressRequired": "請輸入 Leader 錢包地址", + "leaderAddressInvalid": "錢包地址格式不正確(必須是 0x 開頭的 42 位地址)", + "leaderAddressTooltip": "被跟單者的錢包地址,系統將監控該地址的交易並自動跟單", + "leaderName": "Leader 名稱", + "leaderNameTooltip": "可選,用於標識 Leader,方便管理", + "leaderNamePlaceholder": "可選,用於標識 Leader", + "remark": "Leader 備註", + "remarkTooltip": "可選,用於記錄 Leader 的備註信息", + "remarkPlaceholder": "可選,用於記錄 Leader 的備註信息", + "website": "Leader 網站", + "websiteTooltip": "可選,Leader 的網站鏈接", + "websitePlaceholder": "可選,例如:https://example.com", + "websiteInvalid": "請輸入有效的 URL 地址", + "category": "分類篩選", + "categoryTooltip": "僅跟單該分類的交易,不選擇則跟單所有分類(sports 或 crypto)", + "categoryPlaceholder": "選擇分類(可選)", + "add": "添加 Leader", + "cancel": "取消", + "addSuccess": "添加 Leader 成功", + "addFailed": "添加 Leader 失敗" + }, + "leaderEdit": { + "title": "編輯 Leader", + "leaderName": "Leader 名稱", + "leaderNameTooltip": "可選,用於標識 Leader,方便管理", + "leaderNamePlaceholder": "可選,用於標識 Leader", + "remark": "Leader 備註", + "remarkTooltip": "可選,用於記錄 Leader 的備註信息", + "remarkPlaceholder": "可選,用於記錄 Leader 的備註信息", + "website": "Leader 網站", + "websiteTooltip": "可選,Leader 的網站鏈接", + "websitePlaceholder": "可選,例如:https://example.com", + "websiteInvalid": "請輸入有效的 URL 地址", + "category": "分類篩選", + "categoryTooltip": "僅跟單該分類的交易,不選擇則跟單所有分類(sports 或 crypto)", + "categoryPlaceholder": "選擇分類(可選)", + "save": "保存", + "cancel": "取消", + "saveSuccess": "更新 Leader 成功", + "saveFailed": "更新 Leader 失敗", + "fetchFailed": "獲取 Leader 詳情失敗", + "invalidId": "Leader ID 無效" + }, "userList": { "title": "用戶管理", "username": "用戶名", diff --git a/frontend/src/pages/LeaderAdd.tsx b/frontend/src/pages/LeaderAdd.tsx index 864fc07..e02836f 100644 --- a/frontend/src/pages/LeaderAdd.tsx +++ b/frontend/src/pages/LeaderAdd.tsx @@ -4,12 +4,14 @@ import { Card, Form, Input, Button, Select, message, Typography, Space } from 'a import { ArrowLeftOutlined } from '@ant-design/icons' import { apiService } from '../services/api' import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' import { isValidWalletAddress } from '../utils' const { Title } = Typography const { Option } = Select const LeaderAdd: React.FC = () => { + const { t } = useTranslation() const navigate = useNavigate() const isMobile = useMediaQuery({ maxWidth: 768 }) const [form] = Form.useForm() @@ -21,17 +23,19 @@ const LeaderAdd: React.FC = () => { const response = await apiService.leaders.add({ leaderAddress: values.leaderAddress.trim(), leaderName: values.leaderName?.trim() || undefined, + remark: values.remark?.trim() || undefined, + website: values.website?.trim() || undefined, category: values.category || undefined }) if (response.data.code === 0) { - message.success('添加 Leader 成功') + message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功') navigate('/leaders') } else { - message.error(response.data.msg || '添加 Leader 失败') + message.error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败') } } catch (error: any) { - message.error(error.message || '添加 Leader 失败') + message.error(error.message || t('leaderAdd.addFailed') || '添加 Leader 失败') } finally { setLoading(false) } @@ -47,7 +51,7 @@ const LeaderAdd: React.FC = () => { > 返回 - 添加 Leader + {t('leaderAdd.title') || '添加 Leader'} @@ -61,41 +65,68 @@ const LeaderAdd: React.FC = () => { }} > { if (!value) { - return Promise.reject(new Error('请输入 Leader 钱包地址')) + return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址')) } if (!isValidWalletAddress(value.trim())) { - return Promise.reject(new Error('钱包地址格式不正确(必须是 0x 开头的 42 位地址)')) + return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)')) } return Promise.resolve() } } ]} - tooltip="被跟单者的钱包地址,系统将监控该地址的交易并自动跟单" + tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'} > - + - + + + + @@ -109,10 +140,10 @@ const LeaderAdd: React.FC = () => { loading={loading} size={isMobile ? 'middle' : 'large'} > - 添加 Leader + {t('leaderAdd.add') || '添加 Leader'} diff --git a/frontend/src/pages/LeaderEdit.tsx b/frontend/src/pages/LeaderEdit.tsx index 918f7c1..636d848 100644 --- a/frontend/src/pages/LeaderEdit.tsx +++ b/frontend/src/pages/LeaderEdit.tsx @@ -4,12 +4,14 @@ import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } f import { ArrowLeftOutlined } from '@ant-design/icons' import { apiService } from '../services/api' import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' import type { Leader } from '../types' const { Title } = Typography const { Option } = Select const LeaderEdit: React.FC = () => { + const { t } = useTranslation() const navigate = useNavigate() const [searchParams] = useSearchParams() const isMobile = useMediaQuery({ maxWidth: 768 }) @@ -22,7 +24,7 @@ const LeaderEdit: React.FC = () => { if (leaderId) { fetchLeaderDetail(parseInt(leaderId)) } else { - message.error('Leader ID 无效') + message.error(t('leaderEdit.invalidId') || 'Leader ID 无效') navigate('/leaders') } }, [leaderId, navigate]) @@ -35,14 +37,16 @@ const LeaderEdit: React.FC = () => { const leader: Leader = response.data.data form.setFieldsValue({ leaderName: leader.leaderName || '', + remark: leader.remark || '', + website: leader.website || '', category: leader.category || undefined }) } else { - message.error(response.data.msg || '获取 Leader 详情失败') + message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败') navigate('/leaders') } } catch (error: any) { - message.error(error.message || '获取 Leader 详情失败') + message.error(error.message || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败') navigate('/leaders') } finally { setFetching(false) @@ -51,7 +55,7 @@ const LeaderEdit: React.FC = () => { const handleSubmit = async (values: any) => { if (!leaderId) { - message.error('Leader ID 无效') + message.error(t('leaderEdit.invalidId') || 'Leader ID 无效') return } @@ -60,17 +64,19 @@ const LeaderEdit: React.FC = () => { const response = await apiService.leaders.update({ leaderId: parseInt(leaderId), leaderName: values.leaderName?.trim() || undefined, + remark: values.remark?.trim() || undefined, + website: values.website?.trim() || undefined, category: values.category || undefined }) if (response.data.code === 0) { - message.success('更新 Leader 成功') + message.success(t('leaderEdit.saveSuccess') || '更新 Leader 成功') navigate('/leaders') } else { - message.error(response.data.msg || '更新 Leader 失败') + message.error(response.data.msg || t('leaderEdit.saveFailed') || '更新 Leader 失败') } } catch (error: any) { - message.error(error.message || '更新 Leader 失败') + message.error(error.message || t('leaderEdit.saveFailed') || '更新 Leader 失败') } finally { setLoading(false) } @@ -94,7 +100,7 @@ const LeaderEdit: React.FC = () => { > 返回 - 编辑 Leader + {t('leaderEdit.title') || '编辑 Leader'} @@ -105,19 +111,46 @@ const LeaderEdit: React.FC = () => { size={isMobile ? 'middle' : 'large'} > - + - + + + + @@ -131,10 +164,10 @@ const LeaderEdit: React.FC = () => { loading={loading} size={isMobile ? 'middle' : 'large'} > - 保存 + {t('leaderEdit.save') || '保存'} diff --git a/frontend/src/pages/LeaderList.tsx b/frontend/src/pages/LeaderList.tsx index e87ebd9..0998548 100644 --- a/frontend/src/pages/LeaderList.tsx +++ b/frontend/src/pages/LeaderList.tsx @@ -1,12 +1,14 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider } from 'antd' -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' +import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd' +import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, GlobalOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { apiService } from '../services/api' import type { Leader } from '../types' import { useMediaQuery } from 'react-responsive' +const { Text } = Typography + const LeaderList: React.FC = () => { const { t, i18n } = useTranslation() const navigate = useNavigate() @@ -73,6 +75,16 @@ const LeaderList: React.FC = () => { {category} ) : {t('leaderList.all') || '全部'} }, + { + title: t('leaderList.remark') || '备注', + dataIndex: 'remark', + key: 'remark', + render: (remark: string | undefined) => remark ? ( + + {remark} + + ) : - + }, { title: t('leaderList.copyTradingCount') || '跟单关系数', dataIndex: 'copyTradingCount', @@ -97,9 +109,19 @@ const LeaderList: React.FC = () => { { title: t('common.actions') || '操作', key: 'action', - width: isMobile ? 120 : 150, + width: isMobile ? 150 : 200, render: (_: any, record: Leader) => ( - + + {record.website && ( + + )} + )} @@ -243,7 +305,7 @@ const LeaderList: React.FC = () => { size="small" danger icon={} - style={{ flex: 1 }} + style={{ flex: 1, minWidth: '80px' }} > {t('common.delete') || '删除'} diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index 226e7cf..e44f22e 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -1,11 +1,13 @@ 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, SettingOutlined } from '@ant-design/icons' +import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Select, Table, Tag, Popconfirm, Modal } from 'antd' +import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined } from '@ant-design/icons' import { apiService } from '../services/api' import { useMediaQuery } from 'react-responsive' import { useTranslation } from 'react-i18next' +import type { SystemConfig, BuilderApiKeyUpdateRequest, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types' +import { TelegramConfigForm } from '../components/notifications' -const { Title, Text } = Typography +const { Title, Text, Paragraph } = Typography interface ProxyConfig { id?: number @@ -24,53 +26,411 @@ interface ProxyCheckResponse { success: boolean message: string responseTime?: number - latency?: number // 延迟(毫秒) -} - -interface ApiHealthStatus { - name: string - url: string - status: string - message: string - responseTime?: number + latency?: number } const SystemSettings: React.FC = () => { - const { t } = useTranslation() + const { t, i18n: i18nInstance } = useTranslation() const isMobile = useMediaQuery({ maxWidth: 768 }) - const [form] = Form.useForm() + + // 第一部分:多语言 + const [languageForm] = Form.useForm() + const [currentLang, setCurrentLang] = useState('auto') + + // 第二部分:消息推送设置 + const [notificationConfigs, setNotificationConfigs] = useState([]) + const [notificationLoading, setNotificationLoading] = useState(false) + const [notificationModalVisible, setNotificationModalVisible] = useState(false) + const [editingNotificationConfig, setEditingNotificationConfig] = useState(null) + const [notificationForm] = Form.useForm() + const [testLoading, setTestLoading] = useState(false) + + // 第三部分:Relayer配置 + const [relayerForm] = Form.useForm() const [autoRedeemForm] = Form.useForm() - const [loading, setLoading] = useState(false) - const [checking, setChecking] = useState(false) - const [checkResult, setCheckResult] = useState(null) - const [currentConfig, setCurrentConfig] = useState(null) - const [apiHealthStatus, setApiHealthStatus] = useState([]) - const [checkingApiHealth, setCheckingApiHealth] = useState(false) + const [systemConfig, setSystemConfig] = useState(null) + const [relayerLoading, setRelayerLoading] = useState(false) const [autoRedeemLoading, setAutoRedeemLoading] = useState(false) - const [builderApiKeyConfigured, setBuilderApiKeyConfigured] = useState(false) + + // 第四部分:代理设置 + const [proxyForm] = Form.useForm() + const [proxyLoading, setProxyLoading] = useState(false) + const [proxyChecking, setProxyChecking] = useState(false) + const [proxyCheckResult, setProxyCheckResult] = useState(null) + const [currentProxyConfig, setCurrentProxyConfig] = useState(null) useEffect(() => { - fetchConfig() - checkApiHealth() + // 初始化多语言设置 + const savedLanguage = localStorage.getItem('i18n_language') || 'auto' + setCurrentLang(savedLanguage) + languageForm.setFieldsValue({ language: savedLanguage }) + + // 加载其他配置 + fetchNotificationConfigs() fetchSystemConfig() + fetchProxyConfig() }, []) - const fetchConfig = async () => { + // ==================== 第一部分:多语言 ==================== + const detectSystemLanguage = (): string => { + const systemLanguage = navigator.language || navigator.languages?.[0] || 'en' + const lang = systemLanguage.toLowerCase() + if (lang.startsWith('zh')) { + if (lang.includes('tw') || lang.includes('hk') || lang.includes('mo')) { + return 'zh-TW' + } + return 'zh-CN' + } + return 'en' + } + + const handleLanguageSubmit = async (values: { language: string }) => { + try { + let actualLang = values.language + if (values.language === 'auto') { + actualLang = detectSystemLanguage() + localStorage.setItem('i18n_language', 'auto') + } else { + localStorage.setItem('i18n_language', values.language) + } + + setCurrentLang(values.language) + await i18nInstance.changeLanguage(actualLang) + message.success(t('languageSettings.changeSuccess') || '语言设置已保存') + } catch (error) { + message.error(t('languageSettings.changeFailed') || '语言设置保存失败') + } + } + + // ==================== 第二部分:消息推送设置 ==================== + const fetchNotificationConfigs = async () => { + setNotificationLoading(true) + try { + const response = await apiService.notifications.list({ type: 'telegram' }) + if (response.data.code === 0 && response.data.data) { + setNotificationConfigs(response.data.data) + } else { + message.error(response.data.msg || t('notificationSettings.fetchFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.fetchFailed')) + } finally { + setNotificationLoading(false) + } + } + + const handleNotificationCreate = () => { + setEditingNotificationConfig(null) + notificationForm.resetFields() + notificationForm.setFieldsValue({ + type: 'telegram', + enabled: true, + config: { + botToken: '', + chatIds: [] + } + }) + setNotificationModalVisible(true) + } + + const handleNotificationEdit = (config: NotificationConfig) => { + setEditingNotificationConfig(config) + + let botToken = '' + let chatIds = '' + + if (config.config) { + if ('data' in config.config && config.config.data) { + const data = config.config.data as any + botToken = data.botToken || '' + if (data.chatIds) { + if (Array.isArray(data.chatIds)) { + chatIds = data.chatIds.join(',') + } else if (typeof data.chatIds === 'string') { + chatIds = data.chatIds + } + } + } else { + if ('botToken' in config.config) { + botToken = (config.config as any).botToken || '' + } + if ('chatIds' in config.config) { + const ids = (config.config as any).chatIds + if (Array.isArray(ids)) { + chatIds = ids.join(',') + } else if (typeof ids === 'string') { + chatIds = ids + } + } + } + } + + notificationForm.setFieldsValue({ + type: config.type, + name: config.name, + enabled: config.enabled, + config: { + botToken: botToken, + chatIds: chatIds + } + }) + setNotificationModalVisible(true) + } + + const handleNotificationDelete = async (id: number) => { + try { + const response = await apiService.notifications.delete({ id }) + if (response.data.code === 0) { + message.success(t('notificationSettings.deleteSuccess')) + fetchNotificationConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.deleteFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.deleteFailed')) + } + } + + const handleNotificationUpdateEnabled = async (id: number, enabled: boolean) => { + try { + const response = await apiService.notifications.updateEnabled({ id, enabled }) + if (response.data.code === 0) { + message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess')) + fetchNotificationConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.updateStatusFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.updateStatusFailed')) + } + } + + const handleNotificationTest = async () => { + setTestLoading(true) + try { + const response = await apiService.notifications.test({ message: '这是一条测试消息' }) + if (response.data.code === 0 && response.data.data) { + message.success(t('notificationSettings.testSuccess')) + } else { + message.error(response.data.msg || t('notificationSettings.testFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.testFailed')) + } finally { + setTestLoading(false) + } + } + + const handleNotificationSubmit = async () => { + try { + const values = await notificationForm.validateFields() + + const chatIds = typeof values.config.chatIds === 'string' + ? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id) + : values.config.chatIds || [] + + const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = { + type: values.type, + name: values.name, + enabled: values.enabled, + config: { + botToken: values.config.botToken, + chatIds: chatIds + } + } + + if (editingNotificationConfig?.id) { + const updateData = { + ...configData, + id: editingNotificationConfig.id + } as NotificationConfigUpdateRequest + + const response = await apiService.notifications.update(updateData) + if (response.data.code === 0) { + message.success(t('notificationSettings.updateSuccess')) + setNotificationModalVisible(false) + fetchNotificationConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.updateFailed')) + } + } else { + const response = await apiService.notifications.create(configData) + if (response.data.code === 0) { + message.success(t('notificationSettings.createSuccess')) + setNotificationModalVisible(false) + fetchNotificationConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.createFailed')) + } + } + } catch (error: any) { + if (error.errorFields) { + return + } + message.error(error.message || t('message.error')) + } + } + + const notificationColumns = [ + { + title: t('notificationSettings.configName'), + dataIndex: 'name', + key: 'name', + }, + { + title: t('notificationSettings.type'), + dataIndex: 'type', + key: 'type', + render: (type: string) => {type.toUpperCase()} + }, + { + title: t('notificationSettings.status'), + dataIndex: 'enabled', + key: 'enabled', + render: (enabled: boolean) => ( + + {enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')} + + ) + }, + { + title: t('common.actions'), + key: 'action', + width: isMobile ? 120 : 200, + render: (_: any, record: NotificationConfig) => ( + + + handleNotificationUpdateEnabled(record.id!, checked)} + /> + + handleNotificationDelete(record.id!)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + + ) + } + ] + + // ==================== 第三部分:Relayer配置 ==================== + const fetchSystemConfig = async () => { + try { + const response = await apiService.systemConfig.get() + if (response.data.code === 0 && response.data.data) { + const config = response.data.data + setSystemConfig(config) + relayerForm.setFieldsValue({ + builderApiKey: '', + builderSecret: '', + builderPassphrase: '', + }) + autoRedeemForm.setFieldsValue({ + autoRedeemEnabled: config.autoRedeemEnabled + }) + } + } catch (error: any) { + console.error('获取系统配置失败:', error) + } + } + + const handleRelayerSubmit = async (values: BuilderApiKeyUpdateRequest) => { + setRelayerLoading(true) + try { + const updateData: BuilderApiKeyUpdateRequest = {} + if (values.builderApiKey && values.builderApiKey.trim()) { + updateData.builderApiKey = values.builderApiKey.trim() + } + if (values.builderSecret && values.builderSecret.trim()) { + updateData.builderSecret = values.builderSecret.trim() + } + if (values.builderPassphrase && values.builderPassphrase.trim()) { + updateData.builderPassphrase = values.builderPassphrase.trim() + } + + if (!updateData.builderApiKey && !updateData.builderSecret && !updateData.builderPassphrase) { + message.warning(t('builderApiKey.noChanges') || '没有需要更新的字段') + setRelayerLoading(false) + return + } + + const response = await apiService.systemConfig.updateBuilderApiKey(updateData) + if (response.data.code === 0) { + message.success(t('builderApiKey.saveSuccess')) + fetchSystemConfig() + relayerForm.resetFields() + } else { + message.error(response.data.msg || t('builderApiKey.saveFailed')) + } + } catch (error: any) { + message.error(error.message || t('builderApiKey.saveFailed')) + } finally { + setRelayerLoading(false) + } + } + + const handleAutoRedeemSubmit = async (values: { autoRedeemEnabled: boolean }) => { + setAutoRedeemLoading(true) + try { + const response = await apiService.systemConfig.updateAutoRedeem({ enabled: values.autoRedeemEnabled }) + 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) + } + } + + // ==================== 第四部分:代理设置 ==================== + const fetchProxyConfig = async () => { try { const response = await apiService.proxyConfig.get() if (response.data.code === 0) { const data = response.data.data - setCurrentConfig(data) + setCurrentProxyConfig(data) if (data) { - form.setFieldsValue({ + proxyForm.setFieldsValue({ enabled: data.enabled, host: data.host || '', port: data.port || undefined, username: data.username || '', - password: '', // 密码不预填充 + password: '', }) } else { - form.resetFields() + proxyForm.resetFields() } } else { message.error(response.data.msg || '获取代理配置失败') @@ -80,42 +440,38 @@ const SystemSettings: React.FC = () => { } } - const handleSubmit = async (values: any) => { - setLoading(true) + const handleProxySubmit = async (values: any) => { + setProxyLoading(true) try { const response = await apiService.proxyConfig.saveHttp({ enabled: values.enabled || false, host: values.host, port: values.port, username: values.username || undefined, - password: values.password || undefined, // 如果密码为空,则不更新密码 + password: values.password || undefined, }) if (response.data.code === 0) { message.success('保存代理配置成功。新配置将立即生效,已建立的 WebSocket 连接需要重新连接才能使用新代理。') - fetchConfig() - setCheckResult(null) // 清除检查结果 - // 自动刷新 API 健康状态,验证新配置是否生效 - setTimeout(() => { - checkApiHealth() - }, 1000) + fetchProxyConfig() + setProxyCheckResult(null) } else { message.error(response.data.msg || '保存代理配置失败') } } catch (error: any) { message.error(error.message || '保存代理配置失败') } finally { - setLoading(false) + setProxyLoading(false) } } - const handleCheck = async () => { - setChecking(true) - setCheckResult(null) + const handleProxyCheck = async () => { + setProxyChecking(true) + setProxyCheckResult(null) try { const response = await apiService.proxyConfig.check() if (response.data.code === 0 && response.data.data) { const result = response.data.data - setCheckResult(result) + setProxyCheckResult(result) if (result.success) { message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`) } else { @@ -127,208 +483,310 @@ const SystemSettings: React.FC = () => { } catch (error: any) { message.error(error.message || '代理检查失败') } finally { - setChecking(false) - } - } - - const checkApiHealth = async () => { - setCheckingApiHealth(true) - try { - const response = await apiService.proxyConfig.checkApiHealth() - if (response.data.code === 0 && response.data.data) { - setApiHealthStatus(response.data.data.apis) - } else { - message.error(response.data.msg || 'API 健康检查失败') - } - } catch (error: any) { - message.error(error.message || 'API 健康检查失败') - } finally { - setCheckingApiHealth(false) - } - } - - const getStatusColor = (status: string) => { - if (status === 'success') { - return '#52c41a' - } else if (status === 'skipped') { - return '#999' - } else { - return '#ff4d4f' - } - } - - const getStatusText = (status: string) => { - if (status === 'success') { - return '正常' - } else if (status === 'skipped') { - return '未配置' - } else { - return '异常' - } - } - - 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) + setProxyChecking(false) } } return (
- 系统管理 + {t('systemSettings.title') || '通用设置'}
+ {/* 第一部分:多语言 */} - API 健康状态 + {t('systemSettings.language.title') || '多语言设置'} + + } + style={{ marginBottom: '16px' }} + > +
+ + + + + + + + + + + + + { + return prevValues.type !== currentValues.type || + prevValues.config !== currentValues.config + }}> + {() => { + const currentType = notificationForm.getFieldValue('type') || 'telegram' + if (currentType === 'telegram') { + return + } + return null + }} + +
+
- + {/* 第三部分:Relayer配置 */} + + + {t('systemSettings.relayer.title') || 'Relayer 配置'} + + } + style={{ marginBottom: '16px' }} + > + {/* Builder API Key 配置 */} +
+ + {t('builderApiKey.title') || 'Builder API Key'} + + + + {t('builderApiKey.description')} + + + {t('builderApiKey.purposeTitle')} +
    +
  • {t('builderApiKey.purpose1')}
  • +
  • {t('builderApiKey.purpose2')}
  • +
  • {t('builderApiKey.purpose3')}
  • +
+
+ + {t('builderApiKey.getApiKey')} + + + {t('builderApiKey.openSettings')} + + + +
+ } + type="info" + showIcon + style={{ marginBottom: '16px' }} + /> + +
+ + + + + + + + + + + + + + + +
+
+ + {/* 自动赎回配置 */} +
+ + {t('systemSettings.autoRedeem.title') || '自动赎回'} + +
+ + + + + {!systemConfig?.builderApiKeyConfigured && ( + + )} + + + + + +
+
+ + {/* 第四部分:代理设置 */} + + + {t('systemSettings.proxy.title') || '代理设置'} + + } + style={{ marginBottom: '16px' }} + >
@@ -336,45 +794,45 @@ const SystemSettings: React.FC = () => { - + - + - + @@ -383,40 +841,40 @@ const SystemSettings: React.FC = () => { type="primary" htmlType="submit" icon={} - loading={loading} + loading={proxyLoading} > - 保存配置 + {t('common.save') || '保存配置'} - {checkResult && ( + {proxyCheckResult && ( )}
- {checkResult && ( + {proxyCheckResult && ( - {checkResult.message} - {(checkResult.responseTime !== undefined || checkResult.latency !== undefined) && ( + {proxyCheckResult.message} + {(proxyCheckResult.responseTime !== undefined || proxyCheckResult.latency !== undefined) && (
- 延迟: {(checkResult.latency ?? checkResult.responseTime) ?? 0}ms + {t('proxySettings.latency') || '延迟'}: {(proxyCheckResult.latency ?? proxyCheckResult.responseTime) ?? 0}ms
)} @@ -427,68 +885,8 @@ const SystemSettings: React.FC = () => { /> )}
- - - - {t('systemSettings.autoRedeem.title') || '自动赎回配置'} - - } - style={{ marginBottom: '16px' }} - > -
- - - - - {!builderApiKeyConfigured && ( - - {t('systemSettings.autoRedeem.builderApiKeyNotConfiguredDesc') || '自动赎回功能需要配置 Builder API Key 才能生效。'} - - - } - type="warning" - showIcon - style={{ marginBottom: '16px' }} - /> - )} - - - - - -
) } export default SystemSettings - diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0cad413..e14eebc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -285,13 +285,13 @@ export const apiService = { /** * 添加 Leader */ - add: (data: { leaderAddress: string; leaderName?: string; category?: string }) => + add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) => apiClient.post>('/copy-trading/leaders/add', data), /** * 更新 Leader */ - update: (data: { leaderId: number; leaderName?: string; category?: string }) => + update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) => apiClient.post>('/copy-trading/leaders/update', data), /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d783081..3794000 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -60,6 +60,8 @@ export interface Leader { leaderAddress: string leaderName?: string category?: string + remark?: string // Leader 备注(可选) + website?: string // Leader 网站(可选) copyTradingCount: number totalOrders?: number totalPnl?: string @@ -772,7 +774,7 @@ export interface SystemConfig { builderApiKeyConfigured: boolean builderSecretConfigured: boolean builderPassphraseConfigured: boolean - autoRedeem: boolean // 自动赎回(系统级别配置,默认开启) + autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启) } /**