From 4679080d55012bec0e9fae112db628f22b396f4d Mon Sep 17 00:00:00 2001 From: WrBug Date: Wed, 3 Dec 2025 01:44:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=88=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=92=8CAPI=E5=81=A5=E5=BA=B7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增代理配置管理功能,支持HTTP代理配置(host、port、用户名、密码) - 代理配置存储在数据库中,支持实时生效,无需重启服务 - 代理配置变更时自动重连WebSocket连接(订单推送和跟单服务) - 新增API健康状态检查功能,监控Polymarket各API和Polygon RPC的可用性 - 优化API健康状态UI,支持响应式设计(移动端和桌面端) - 将Ethereum RPC相关配置和代码统一改为Polygon RPC - 修复代理检查时的SSL证书验证问题 - 优化代理配置:关闭代理时保留配置信息,避免重新输入 --- .../config/ProxyConfigInitializer.kt | 28 ++ .../controller/ProxyConfigController.kt | 125 ++++++ .../polymarketbot/dto/ApiHealthCheckDto.kt | 20 + .../wrbug/polymarketbot/dto/ProxyConfigDto.kt | 59 +++ .../wrbug/polymarketbot/entity/ProxyConfig.kt | 49 +++ .../repository/ProxyConfigRepository.kt | 27 ++ .../service/ApiHealthCheckService.kt | 378 +++++++++++++++++ .../service/BlockchainService.kt | 56 +-- .../service/CopyTradingWebSocketService.kt | 39 ++ .../polymarketbot/service/OrderPushService.kt | 30 ++ .../service/ProxyConfigService.kt | 328 +++++++++++++++ .../com/wrbug/polymarketbot/util/OkHttpExt.kt | 70 ++- .../polymarketbot/util/ProxyConfigProvider.kt | 54 +++ .../websocket/PolymarketWebSocketClient.kt | 2 +- .../src/main/resources/application.properties | 6 +- ...0__add_outcome_index_to_order_tracking.sql | 18 - .../db/migration/V11__create_users_table.sql | 10 - .../V12__add_is_default_to_users.sql | 8 - ...V1__create_copy_trading_accounts_table.sql | 14 - .../db/migration/V1__init_database.sql | 211 ++++++++++ ..._api_secret_and_passphrase_to_accounts.sql | 5 - .../V2__create_proxy_config_table.sql | 20 + .../V3__add_proxy_address_to_accounts.sql | 4 - .../V4__add_is_enabled_to_accounts.sql | 4 - .../V5__create_copy_trading_leaders_table.sql | 12 - ...6__create_copy_trading_templates_table.sql | 23 - .../V7__create_copy_trading_table.sql | 19 - .../V8__create_order_tracking_tables.sql | 76 ---- .../V9__add_failed_trade_tracking.sql | 27 -- frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 8 +- frontend/src/pages/SystemSettings.tsx | 397 ++++++++++++++++++ frontend/src/services/api.ts | 59 +++ 33 files changed, 1898 insertions(+), 290 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/config/ProxyConfigInitializer.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/ProxyConfigController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiHealthCheckDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProxyConfig.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProxyConfigRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/ProxyConfigService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/ProxyConfigProvider.kt delete mode 100644 backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql delete mode 100644 backend/src/main/resources/db/migration/V11__create_users_table.sql delete mode 100644 backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql delete mode 100644 backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql create mode 100644 backend/src/main/resources/db/migration/V1__init_database.sql delete mode 100644 backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql create mode 100644 backend/src/main/resources/db/migration/V2__create_proxy_config_table.sql delete mode 100644 backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql delete mode 100644 backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql delete mode 100644 backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql delete mode 100644 backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql delete mode 100644 backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql delete mode 100644 backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql delete mode 100644 backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql create mode 100644 frontend/src/pages/SystemSettings.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/ProxyConfigInitializer.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/ProxyConfigInitializer.kt new file mode 100644 index 0000000..9bcd437 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/ProxyConfigInitializer.kt @@ -0,0 +1,28 @@ +package com.wrbug.polymarketbot.config + +import com.wrbug.polymarketbot.service.ProxyConfigService +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +/** + * 代理配置初始化器 + * 在应用启动时初始化代理配置 + */ +@Component +class ProxyConfigInitializer( + private val proxyConfigService: ProxyConfigService +) { + + private val logger = LoggerFactory.getLogger(ProxyConfigInitializer::class.java) + + @PostConstruct + fun init() { + try { + proxyConfigService.initProxyConfig() + } catch (e: Exception) { + logger.error("初始化代理配置失败", e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/ProxyConfigController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/ProxyConfigController.kt new file mode 100644 index 0000000..a41dd5d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/ProxyConfigController.kt @@ -0,0 +1,125 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.ApiHealthCheckService +import com.wrbug.polymarketbot.service.ProxyConfigService +import jakarta.servlet.http.HttpServletRequest +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 代理配置控制器 + */ +@RestController +@RequestMapping("/api/proxy-config") +class ProxyConfigController( + private val proxyConfigService: ProxyConfigService, + private val apiHealthCheckService: ApiHealthCheckService +) { + + private val logger = LoggerFactory.getLogger(ProxyConfigController::class.java) + + /** + * 获取当前代理配置 + */ + @PostMapping("/get") + fun getProxyConfig(): ResponseEntity> { + return try { + val config = proxyConfigService.getProxyConfig() + ResponseEntity.ok(ApiResponse.success(config)) + } catch (e: Exception) { + logger.error("获取代理配置失败", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取代理配置失败:${e.message}")) + } + } + + /** + * 获取所有代理配置(用于管理) + */ + @PostMapping("/list") + fun getAllProxyConfigs(): ResponseEntity>> { + return try { + val configs = proxyConfigService.getAllProxyConfigs() + ResponseEntity.ok(ApiResponse.success(configs)) + } catch (e: Exception) { + logger.error("获取代理配置列表失败", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取代理配置列表失败:${e.message}")) + } + } + + /** + * 保存 HTTP 代理配置 + */ + @PostMapping("/http/save") + fun saveHttpProxyConfig(@RequestBody request: HttpProxyConfigRequest): ResponseEntity> { + return try { + val result = proxyConfigService.saveHttpProxyConfig(request) + if (result.isSuccess) { + ResponseEntity.ok(ApiResponse.success(result.getOrNull())) + } else { + val error = result.exceptionOrNull() + logger.error("保存 HTTP 代理配置失败", error) + ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, error?.message ?: "保存失败")) + } + } catch (e: Exception) { + logger.error("保存 HTTP 代理配置异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "保存 HTTP 代理配置失败:${e.message}")) + } + } + + /** + * 检查代理是否可用 + */ + @PostMapping("/check") + fun checkProxy(): ResponseEntity> { + return try { + val result = proxyConfigService.checkProxy() + ResponseEntity.ok(ApiResponse.success(result)) + } catch (e: Exception) { + logger.error("代理检查失败", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "代理检查失败:${e.message}")) + } + } + + /** + * 删除代理配置 + */ + @PostMapping("/delete") + fun deleteProxyConfig(@RequestBody request: Map): ResponseEntity> { + return try { + val id = request["id"] ?: return ResponseEntity.ok( + ApiResponse.error(ErrorCode.PARAM_ERROR, "参数错误:缺少 id") + ) + + val result = proxyConfigService.deleteProxyConfig(id) + if (result.isSuccess) { + ResponseEntity.ok(ApiResponse.success(Unit)) + } else { + val error = result.exceptionOrNull() + logger.error("删除代理配置失败:id=$id", error) + ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, error?.message ?: "删除失败")) + } + } catch (e: Exception) { + logger.error("删除代理配置异常", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "删除代理配置失败:${e.message}")) + } + } + + /** + * 检查所有 API 的健康状态 + */ + @PostMapping("/api-health-check") + fun checkApiHealth(): ResponseEntity> { + return try { + val result = runBlocking { apiHealthCheckService.checkAllApis() } + ResponseEntity.ok(ApiResponse.success(result)) + } catch (e: Exception) { + logger.error("API 健康检查失败", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiHealthCheckDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiHealthCheckDto.kt new file mode 100644 index 0000000..c47e182 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiHealthCheckDto.kt @@ -0,0 +1,20 @@ +package com.wrbug.polymarketbot.dto + +/** + * API 健康检查响应 + */ +data class ApiHealthCheckDto( + val name: String, // API 名称 + val url: String, // API URL + val status: String, // "success" 或 "error" + val message: String, // 状态消息 + val responseTime: Long? = null // 响应时间(毫秒) +) + +/** + * 所有 API 健康检查响应 + */ +data class ApiHealthCheckResponse( + val apis: List +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt new file mode 100644 index 0000000..a9378f3 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ProxyConfigDto.kt @@ -0,0 +1,59 @@ +package com.wrbug.polymarketbot.dto + +/** + * 代理配置 DTO(用于返回给前端,不包含密码) + */ +data class ProxyConfigDto( + val id: Long?, + val type: String, // HTTP, CLASH, SS + val enabled: Boolean, + val host: String?, + val port: Int?, + val username: String?, + val subscriptionUrl: String?, + val lastSubscriptionUpdate: Long?, + val createdAt: Long, + val updatedAt: Long +) + +/** + * 创建/更新 HTTP 代理配置请求 + */ +data class HttpProxyConfigRequest( + val enabled: Boolean, + val host: String, + val port: Int, + val username: String? = null, + val password: String? = null // 更新时如果为空则不更新密码 +) + +/** + * 创建/更新订阅代理配置请求(第二阶段功能) + */ +data class SubscriptionProxyConfigRequest( + val enabled: Boolean, + val subscriptionUrl: String, + val type: String // CLASH 或 SS +) + +/** + * 代理检查响应 + */ +data class ProxyCheckResponse( + val success: Boolean, + val message: String, + val responseTime: Long? = null, // 响应时间(毫秒) + val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示 +) { + companion object { + fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse { + return ProxyCheckResponse( + success = success, + message = message, + responseTime = responseTime, + latency = responseTime // latency 和 responseTime 相同 + ) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProxyConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProxyConfig.kt new file mode 100644 index 0000000..6544d5d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/ProxyConfig.kt @@ -0,0 +1,49 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 代理配置实体 + * 支持 HTTP 代理和订阅代理(Clash/SS) + */ +@Entity +@Table(name = "proxy_config") +data class ProxyConfig( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "type", nullable = false, length = 20) + val type: String, // HTTP, CLASH, SS + + @Column(name = "enabled", nullable = false) + val enabled: Boolean = false, + + @Column(name = "host", length = 255) + val host: String? = null, // HTTP 代理主机 + + @Column(name = "port") + val port: Int? = null, // HTTP 代理端口 + + @Column(name = "username", length = 100) + val username: String? = null, // HTTP 代理用户名(可选) + + @Column(name = "password", length = 255) + val password: String? = null, // HTTP 代理密码(BCrypt加密,可选) + + @Column(name = "subscription_url", length = 500) + val subscriptionUrl: String? = null, // 订阅链接(Clash/SS) + + @Column(name = "subscription_config", columnDefinition = "TEXT") + val subscriptionConfig: String? = null, // 订阅配置内容(JSON格式) + + @Column(name = "last_subscription_update") + val lastSubscriptionUpdate: Long? = null, // 最后订阅更新时间 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProxyConfigRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProxyConfigRepository.kt new file mode 100644 index 0000000..8c60dc8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/ProxyConfigRepository.kt @@ -0,0 +1,27 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.ProxyConfig +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 代理配置 Repository + */ +@Repository +interface ProxyConfigRepository : JpaRepository { + /** + * 查找启用的代理配置 + */ + fun findByEnabledTrue(): ProxyConfig? + + /** + * 根据类型查找启用的代理配置 + */ + fun findByTypeAndEnabledTrue(type: String): ProxyConfig? + + /** + * 根据类型查找代理配置(无论是否启用) + */ + fun findByType(type: String): ProxyConfig? +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt new file mode 100644 index 0000000..6de2ece --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt @@ -0,0 +1,378 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.ApiHealthCheckDto +import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse +import com.wrbug.polymarketbot.util.createClient +import kotlinx.coroutines.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import org.slf4j.LoggerFactory +import org.springframework.beans.BeansException +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +/** + * API 健康检查服务 + */ +@Service +class ApiHealthCheckService( + @Value("\${polymarket.clob.base-url}") + private val clobBaseUrl: String, + @Value("\${polymarket.data-api.base-url}") + private val dataApiBaseUrl: String, + @Value("\${polymarket.gamma.base-url}") + private val gammaBaseUrl: String, + @Value("\${polygon.rpc.url:}") + private val polygonRpcUrl: String, + @Value("\${polymarket.rtds.ws-url}") + private val polymarketWsUrl: String +) : ApplicationContextAware { + + private var applicationContext: ApplicationContext? = null + + override fun setApplicationContext(applicationContext: ApplicationContext) { + this.applicationContext = applicationContext + } + + /** + * 获取订单推送服务(通过 ApplicationContext 避免循环依赖) + */ + private fun getOrderPushService(): OrderPushService? { + return try { + applicationContext?.getBean(OrderPushService::class.java) + } catch (e: BeansException) { + null + } + } + + /** + * 获取跟单 WebSocket 服务(通过 ApplicationContext 避免循环依赖) + */ + private fun getCopyTradingWebSocketService(): CopyTradingWebSocketService? { + return try { + applicationContext?.getBean(CopyTradingWebSocketService::class.java) + } catch (e: BeansException) { + null + } + } + + private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java) + + /** + * 检查所有 API 的健康状态 + */ + suspend fun checkAllApis(): ApiHealthCheckResponse { + val apis = mutableListOf() + + // 并行检查所有 API + coroutineScope { + val jobs = listOf( + async { checkClobApi() }, + async { checkDataApi() }, + async { checkGammaApi() }, + async { checkPolygonRpc() }, + async { checkPolymarketWebSocket() } + ) + + jobs.awaitAll().forEach { result -> + apis.add(result) + } + } + + return ApiHealthCheckResponse(apis = apis) + } + + /** + * 检查 Polymarket CLOB API + */ + private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) { + val url = "$clobBaseUrl/" + checkApi("Polymarket CLOB API", url) + } + + /** + * 检查 Polymarket Data API + */ + private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) { + val url = "$dataApiBaseUrl/" + checkApi("Polymarket Data API", url) + } + + /** + * 检查 Polymarket Gamma API + * 使用 /markets 接口检查 API 可用性(调用实际的业务接口) + */ + private suspend fun checkGammaApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) { + return@withContext try { + val client = createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + // 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据) + val url = "$gammaBaseUrl/markets" + val request = Request.Builder() + .url(url) + .get() + .build() + + val startTime = System.currentTimeMillis() + val response = client.newCall(request).execute() + val responseTime = System.currentTimeMillis() - startTime + + if (response.isSuccessful) { + // 检查响应体是否为有效的 JSON 数组(即使为空数组也可以) + val responseBody = response.body?.string() + if (responseBody != null && (responseBody.trim().startsWith("[") || responseBody.trim() + .startsWith("{")) + ) { + ApiHealthCheckDto( + name = "Polymarket Gamma API", + url = url, + status = "success", + message = "连接成功", + responseTime = responseTime + ) + } else { + ApiHealthCheckDto( + name = "Polymarket Gamma API", + url = url, + status = "error", + message = "响应格式不正确", + responseTime = responseTime + ) + } + } else { + ApiHealthCheckDto( + name = "Polymarket Gamma API", + url = url, + status = "error", + message = "HTTP ${response.code}: ${response.message}", + responseTime = responseTime + ) + } + } catch (e: Exception) { + logger.warn("检查 Polymarket Gamma API 失败", e) + ApiHealthCheckDto( + name = "Polymarket Gamma API", + url = "$gammaBaseUrl/markets", + status = "error", + message = e.message ?: "连接失败" + ) + } + } + + /** + * 检查 Polygon RPC + */ + private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) { + if (polygonRpcUrl.isBlank()) { + return@withContext ApiHealthCheckDto( + name = "Polygon RPC", + url = "未配置", + status = "skipped", + message = "未配置 Polygon RPC URL" + ) + } + + val url = polygonRpcUrl + checkJsonRpcApi("Polygon RPC", url) + } + + /** + * 检查 Polymarket WebSocket 连接状态 + * 不显示延时,只显示连接状态 + */ + private suspend fun checkPolymarketWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) { + try { + // 检查订单推送服务的连接状态 + val orderPushService = getOrderPushService() + val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap() + val orderPushConnected = orderPushStatuses.values.any { it } + val orderPushTotal = orderPushStatuses.size + val orderPushConnectedCount = orderPushStatuses.values.count { it } + + // 检查跟单 WebSocket 服务的连接状态 + val copyTradingWebSocketService = getCopyTradingWebSocketService() + val copyTradingStatuses = copyTradingWebSocketService?.getConnectionStatuses() ?: emptyMap() + val copyTradingConnected = copyTradingStatuses.values.any { it } + val copyTradingTotal = copyTradingStatuses.size + val copyTradingConnectedCount = copyTradingStatuses.values.count { it } + + // 计算总体状态 + val totalConnections = orderPushTotal + copyTradingTotal + val connectedConnections = orderPushConnectedCount + copyTradingConnectedCount + + val url = polymarketWsUrl + val hasAnyConnection = orderPushConnected || copyTradingConnected + + if (totalConnections == 0) { + // 没有配置任何 WebSocket 连接 + ApiHealthCheckDto( + name = "Polymarket WebSocket", + url = url, + status = "skipped", + message = "未配置 WebSocket 连接" + ) + } else if (hasAnyConnection) { + // 至少有一个连接是活跃的 + val message = if (connectedConnections == totalConnections) { + "所有连接正常 ($connectedConnections/$totalConnections)" + } else { + "部分连接正常 ($connectedConnections/$totalConnections)" + } + ApiHealthCheckDto( + name = "Polymarket WebSocket", + url = url, + status = "success", + message = message + // 不设置 responseTime,WebSocket 不显示延时 + ) + } else { + // 所有连接都断开 + ApiHealthCheckDto( + name = "Polymarket WebSocket", + url = url, + status = "error", + message = "所有连接断开 ($connectedConnections/$totalConnections)" + // 不设置 responseTime,WebSocket 不显示延时 + ) + } + } catch (e: Exception) { + logger.warn("检查 Polymarket WebSocket 状态失败", e) + ApiHealthCheckDto( + name = "Polymarket WebSocket", + url = polymarketWsUrl, + status = "error", + message = "检查失败:${e.message}" + ) + } + } + + /** + * 检查普通 HTTP API + */ + private suspend fun checkApi(name: String, url: String): ApiHealthCheckDto { + return try { + val client = createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + val request = Request.Builder() + .url(url) + .get() + .build() + + val startTime = System.currentTimeMillis() + val response = client.newCall(request).execute() + val responseTime = System.currentTimeMillis() - startTime + + if (response.isSuccessful) { + ApiHealthCheckDto( + name = name, + url = url, + status = "success", + message = "连接成功", + responseTime = responseTime + ) + } else { + ApiHealthCheckDto( + name = name, + url = url, + status = "error", + message = "HTTP ${response.code}: ${response.message}", + responseTime = responseTime + ) + } + } catch (e: Exception) { + logger.warn("检查 API 失败: $name ($url)", e) + ApiHealthCheckDto( + name = name, + url = url, + status = "error", + message = e.message ?: "连接失败" + ) + } + } + + /** + * 检查 JSON-RPC API(如 Polygon RPC) + */ + private suspend fun checkJsonRpcApi(name: String, url: String): ApiHealthCheckDto { + return try { + val client = createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + // 发送一个简单的 JSON-RPC 请求(获取链 ID) + val jsonRpcRequest = """ + { + "jsonrpc": "2.0", + "method": "eth_chainId", + "params": [], + "id": 1 + } + """.trimIndent() + + val mediaType = "application/json; charset=utf-8".toMediaTypeOrNull() + ?: "application/json".toMediaType() + + val request = Request.Builder() + .url(url) + .post(okhttp3.RequestBody.create(mediaType, jsonRpcRequest)) + .header("Content-Type", "application/json") + .build() + + val startTime = System.currentTimeMillis() + val response = client.newCall(request).execute() + val responseTime = System.currentTimeMillis() - startTime + + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (responseBody != null && responseBody.contains("\"result\"")) { + ApiHealthCheckDto( + name = name, + url = url, + status = "success", + message = "连接成功", + responseTime = responseTime + ) + } else { + ApiHealthCheckDto( + name = name, + url = url, + status = "error", + message = "响应格式不正确", + responseTime = responseTime + ) + } + } else { + ApiHealthCheckDto( + name = name, + url = url, + status = "error", + message = "HTTP ${response.code}: ${response.message}", + responseTime = responseTime + ) + } + } catch (e: Exception) { + logger.warn("检查 JSON-RPC API 失败: $name ($url)", e) + ApiHealthCheckDto( + name = name, + url = url, + status = "error", + message = e.message ?: "连接失败" + ) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt index 009409a..47c7eb4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt @@ -25,8 +25,8 @@ import java.math.BigInteger class BlockchainService( @Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}") private val dataApiBaseUrl: String, - @Value("\${ethereum.rpc.url:}") - private val ethereumRpcUrl: String, + @Value("\${polygon.rpc.url:}") + private val polygonRpcUrl: String, private val retrofitFactory: RetrofitFactory ) { @@ -67,11 +67,11 @@ class BlockchainService( .create(PolymarketDataApi::class.java) } - private val ethereumRpcApi: EthereumRpcApi? by lazy { - if (ethereumRpcUrl.isBlank()) { + private val polygonRpcApi: EthereumRpcApi? by lazy { + if (polygonRpcUrl.isBlank()) { null } else { - retrofitFactory.createEthereumRpcApi(ethereumRpcUrl) + retrofitFactory.createEthereumRpcApi(polygonRpcUrl) } } @@ -84,12 +84,12 @@ class BlockchainService( suspend fun getProxyAddress(walletAddress: String): Result { return try { // 如果未配置 RPC URL,返回错误 - if (ethereumRpcUrl.isBlank()) { - logger.warn("未配置 Ethereum RPC URL,无法获取代理地址") - return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法获取代理地址。请在配置文件中设置 ethereum.rpc.url 环境变量")) + if (polygonRpcUrl.isBlank()) { + logger.warn("未配置 Polygon RPC URL,无法获取代理地址") + return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法获取代理地址。请在配置文件中设置 polygon.rpc.url 环境变量")) } - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") // 计算函数选择器 val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature) @@ -138,7 +138,7 @@ class BlockchainService( /** * 查询账户 USDC 余额 - * 通过 Ethereum RPC 查询 ERC-20 代币余额 + * 通过 Polygon RPC 查询 ERC-20 代币余额 * @param walletAddress 钱包地址(用于日志记录) * @param proxyAddress 代理地址(必须提供) * 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据) @@ -146,9 +146,9 @@ class BlockchainService( suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result { return try { // 如果未配置 RPC URL,返回错误 - if (ethereumRpcUrl.isBlank()) { - logger.warn("未配置 Ethereum RPC URL,无法查询 USDC 余额") - return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法查询 USDC 余额。请在配置文件中设置 ethereum.rpc.url 环境变量")) + if (polygonRpcUrl.isBlank()) { + logger.warn("未配置 Polygon RPC URL,无法查询 USDC 余额") + return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法查询 USDC 余额。请在配置文件中设置 polygon.rpc.url 环境变量")) } // 检查代理地址是否为空 @@ -171,7 +171,7 @@ class BlockchainService( * 通过 RPC 查询 USDC 余额 */ private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String { - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") // 构建 ERC-20 balanceOf 函数调用 // function signature: balanceOf(address) -> bytes4(0x70a08231) @@ -263,12 +263,12 @@ class BlockchainService( suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result { return try { // 如果未配置 RPC URL,返回错误 - if (ethereumRpcUrl.isBlank()) { - logger.warn("未配置 Ethereum RPC URL,无法计算 tokenId") - return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法计算 tokenId")) + if (polygonRpcUrl.isBlank()) { + logger.warn("未配置 Polygon RPC URL,无法计算 tokenId") + return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法计算 tokenId")) } - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") // 验证 outcomeIndex if (outcomeIndex < 0) { @@ -422,12 +422,12 @@ class BlockchainService( ): Result { return try { // 如果未配置 RPC URL,返回错误 - if (ethereumRpcUrl.isBlank()) { - logger.warn("未配置 Ethereum RPC URL,无法赎回仓位") - return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法赎回仓位")) + if (polygonRpcUrl.isBlank()) { + logger.warn("未配置 Polygon RPC URL,无法赎回仓位") + return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法赎回仓位")) } - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") // 验证参数 if (indexSets.isEmpty()) { @@ -650,7 +650,7 @@ class BlockchainService( * 获取代理钱包的 nonce(用于构建 Safe 交易) */ private suspend fun getProxyNonce(proxyAddress: String): Result { - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") // Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取 val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()") @@ -685,7 +685,7 @@ class BlockchainService( * 获取交易 nonce */ private suspend fun getTransactionCount(address: String): Result { - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") val rpcRequest = JsonRpcRequest( method = "eth_getTransactionCount", @@ -713,7 +713,7 @@ class BlockchainService( * 获取 gas price */ private suspend fun getGasPrice(): Result { - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") val rpcRequest = JsonRpcRequest( method = "eth_gasPrice", @@ -815,11 +815,11 @@ class BlockchainService( */ suspend fun getTransactionDetails(txHash: String): Result { return try { - if (ethereumRpcUrl.isBlank()) { - return Result.failure(IllegalStateException("未配置 Ethereum RPC URL")) + if (polygonRpcUrl.isBlank()) { + return Result.failure(IllegalStateException("未配置 Polygon RPC URL")) } - val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") // 查询交易 val txRequest = JsonRpcRequest( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt index ec8913d..de24cbd 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingWebSocketService.kt @@ -130,6 +130,45 @@ class CopyTradingWebSocketService( } } + /** + * 重连所有 Leader 的 WebSocket 连接(用于代理配置变更时) + */ + fun reconnectAll() { + logger.info("重连所有 Leader 的 WebSocket 连接(代理配置已更新)") + val leaderIds = leaderClients.keys.toList() + val leaderAddressesMap = leaderAddresses.toMap() + + leaderIds.forEach { leaderId -> + try { + // 断开旧连接 + val oldClient = leaderClients.remove(leaderId) + oldClient?.closeConnection() + + // 重新连接 + val leaderAddress = leaderAddressesMap[leaderId] + if (leaderAddress != null) { + // 重新创建 Leader 对象(简化版,只需要地址) + val leader = Leader( + id = leaderId, + leaderAddress = leaderAddress, + leaderName = null, + category = null + ) + addLeader(leader) + } + } catch (e: Exception) { + logger.error("重连 Leader $leaderId 失败", e) + } + } + } + + /** + * 获取所有 Leader 的连接状态 + */ + fun getConnectionStatuses(): Map { + return leaderClients.mapValues { (_, client) -> client.isConnected() } + } + /** * 订阅用户交易频道 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt index c9e0caa..97f3f57 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt @@ -419,6 +419,36 @@ class OrderPushService( // 如果没有订阅者了,可以考虑关闭连接(但暂时保持连接,以便后续订阅) } + /** + * 重连所有账户的 WebSocket 连接(用于代理配置变更时) + */ + fun reconnectAllAccounts() { + logger.info("重连所有账户的 WebSocket 连接(代理配置已更新)") + val accountIds = accountConnections.keys.toList() + accountIds.forEach { accountId -> + try { + // 断开旧连接 + val oldClient = accountConnections.remove(accountId) + oldClient?.closeConnection() + + // 重新连接 + val account = accountRepository.findById(accountId).orElse(null) + if (account != null && hasApiCredentials(account) && account.isEnabled) { + connectAccount(account) + } + } catch (e: Exception) { + logger.error("重连账户 $accountId 失败", e) + } + } + } + + /** + * 获取所有账户的连接状态 + */ + fun getConnectionStatuses(): Map { + return accountConnections.mapValues { (_, client) -> client.isConnected() } + } + /** * 断开指定账户的连接 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ProxyConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ProxyConfigService.kt new file mode 100644 index 0000000..046037a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ProxyConfigService.kt @@ -0,0 +1,328 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.ProxyConfig +import com.wrbug.polymarketbot.repository.ProxyConfigRepository +import com.wrbug.polymarketbot.util.ProxyConfigProvider +import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier +import com.wrbug.polymarketbot.util.createSSLSocketFactory +import okhttp3.* +import org.slf4j.LoggerFactory +import org.springframework.beans.BeansException +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.TimeUnit + +/** + * 代理配置服务 + */ +@Service +class ProxyConfigService( + private val proxyConfigRepository: ProxyConfigRepository +) : ApplicationContextAware { + + private var applicationContext: ApplicationContext? = null + + override fun setApplicationContext(applicationContext: ApplicationContext) { + this.applicationContext = applicationContext + } + + private val logger = LoggerFactory.getLogger(ProxyConfigService::class.java) + + /** + * 获取当前代理配置 + * 返回 HTTP 类型的代理配置(无论是否启用),以便前端可以显示和编辑配置 + */ + fun getProxyConfig(): ProxyConfigDto? { + // 优先查找 HTTP 类型的代理配置(无论是否启用) + val config = proxyConfigRepository.findByType("HTTP") + ?: return null + + // 如果配置是启用的,更新 ProxyConfigProvider + if (config.enabled) { + ProxyConfigProvider.setProxyConfig(config) + } else { + // 如果配置是禁用的,清除 ProxyConfigProvider(不使用代理) + ProxyConfigProvider.setProxyConfig(null) + } + + return toDto(config) + } + + /** + * 初始化代理配置(应用启动时调用) + */ + fun initProxyConfig() { + val config = proxyConfigRepository.findByEnabledTrue() + ProxyConfigProvider.setProxyConfig(config) + if (config != null) { + logger.info("初始化代理配置:type=${config.type}, host=${config.host}, port=${config.port}, enabled=${config.enabled}") + } else { + logger.info("未找到启用的代理配置") + } + } + + /** + * 获取所有代理配置(用于管理) + */ + fun getAllProxyConfigs(): List { + return proxyConfigRepository.findAll().map { toDto(it) } + } + + /** + * 创建或更新 HTTP 代理配置 + */ + @Transactional + fun saveHttpProxyConfig(request: HttpProxyConfigRequest): Result { + return try { + // 验证参数 + if (request.host.isBlank()) { + return Result.failure(IllegalArgumentException("代理主机不能为空")) + } + if (request.port <= 0 || request.port > 65535) { + return Result.failure(IllegalArgumentException("代理端口必须在 1-65535 之间")) + } + + // 查找现有的 HTTP 代理配置(无论是否启用) + val existing = proxyConfigRepository.findByType("HTTP") + + val config = if (existing != null) { + // 更新现有配置 + val password = if (request.password != null && request.password.isNotBlank()) { + request.password // 明文存储(与 Account 实体保持一致) + } else { + existing.password // 如果密码为空,保持原密码 + } + + existing.copy( + enabled = request.enabled, + host = request.host, + port = request.port, + username = request.username?.takeIf { it.isNotBlank() }, + password = password, + updatedAt = System.currentTimeMillis() + ) + } else { + // 创建新配置 + val password = if (request.password != null && request.password.isNotBlank()) { + request.password // 明文存储(与 Account 实体保持一致) + } else { + null + } + + ProxyConfig( + type = "HTTP", + enabled = request.enabled, + host = request.host, + port = request.port, + username = request.username?.takeIf { it.isNotBlank() }, + password = password + ) + } + + val saved = proxyConfigRepository.save(config) + logger.info("保存 HTTP 代理配置成功:host=${saved.host}, port=${saved.port}, enabled=${saved.enabled}") + + // 更新 ProxyConfigProvider + if (saved.enabled) { + ProxyConfigProvider.setProxyConfig(saved) + } else { + // 如果禁用了代理,清除配置 + ProxyConfigProvider.setProxyConfig(null) + } + + // 触发 WebSocket 重连(使用新代理配置) + triggerWebSocketReconnect() + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("保存 HTTP 代理配置失败", e) + Result.failure(e) + } + } + + /** + * 检查代理是否可用 + * 使用配置的代理请求 Polymarket 健康检查接口 + */ + fun checkProxy(): ProxyCheckResponse { + return try { + val config = proxyConfigRepository.findByEnabledTrue() + ?: return ProxyCheckResponse.create( + success = false, + message = "未配置代理或代理未启用" + ) + + if (config.type != "HTTP") { + return ProxyCheckResponse.create( + success = false, + message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)" + ) + } + + if (config.host == null || config.port == null) { + return ProxyCheckResponse.create( + success = false, + message = "代理配置不完整:缺少主机或端口" + ) + } + + // 创建代理 + val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)) + + // 创建 OkHttpClient + val clientBuilder = OkHttpClient.Builder() + .proxy(proxy) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + + // 配置 SSL:信任所有证书(用于代理连接) + clientBuilder.createSSLSocketFactory() + clientBuilder.hostnameVerifier(TrustAllHostnameVerifier()) + + // 如果配置了用户名和密码,添加代理认证 + if (config.username != null && config.password != null) { + clientBuilder.proxyAuthenticator { _, response -> + val credential = okhttp3.Credentials.basic(config.username, config.password) + response.request.newBuilder() + .header("Proxy-Authorization", credential) + .build() + } + } + + val client = clientBuilder.build() + + // 请求 Polymarket 健康检查接口 + val request = Request.Builder() + .url("https://data-api.polymarket.com/") + .get() + .build() + + val startTime = System.currentTimeMillis() + val response = client.newCall(request).execute() + val responseTime = System.currentTimeMillis() - startTime + + val responseBody = response.body?.string() + + if (response.isSuccessful && responseBody != null) { + // 检查响应内容是否为 {"data": "OK"} + if (responseBody.contains("\"data\"") && responseBody.contains("OK")) { + logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms") + ProxyCheckResponse.create( + success = true, + message = "代理连接成功", + responseTime = responseTime + ) + } else { + ProxyCheckResponse.create( + success = false, + message = "代理连接成功,但响应格式不正确:$responseBody", + responseTime = responseTime + ) + } + } else { + ProxyCheckResponse.create( + success = false, + message = "代理连接失败:HTTP ${response.code} ${response.message}", + responseTime = responseTime + ) + } + } catch (e: Exception) { + logger.error("代理检查异常", e) + ProxyCheckResponse.create( + success = false, + message = "代理检查失败:${e.message}" + ) + } + } + + /** + * 删除代理配置 + */ + @Transactional + fun deleteProxyConfig(id: Long): Result { + return try { + val config = proxyConfigRepository.findById(id) + .orElse(null) ?: return Result.failure(IllegalArgumentException("代理配置不存在")) + + val wasEnabled = config.enabled + proxyConfigRepository.delete(config) + logger.info("删除代理配置成功:id=$id, type=${config.type}") + + // 如果删除的是启用的代理配置,清除 ProxyConfigProvider + if (wasEnabled) { + ProxyConfigProvider.setProxyConfig(null) + // 触发 WebSocket 重连(使用新配置,即无代理) + triggerWebSocketReconnect() + } + + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除代理配置失败:id=$id", e) + Result.failure(e) + } + } + + /** + * 触发所有 WebSocket 重连(使用新代理配置) + */ + private fun triggerWebSocketReconnect() { + try { + val context = applicationContext ?: return + + // 重连订单推送服务的 WebSocket 连接 + try { + val orderPushService = context.getBean(OrderPushService::class.java) + kotlinx.coroutines.runBlocking { + try { + orderPushService.reconnectAllAccounts() + logger.info("已触发订单推送服务 WebSocket 重连") + } catch (e: Exception) { + logger.error("触发订单推送服务重连失败", e) + } + } + } catch (e: BeansException) { + logger.debug("订单推送服务未找到,跳过重连", e) + } + + // 重连跟单 WebSocket 服务的连接 + try { + val copyTradingWebSocketService = context.getBean(CopyTradingWebSocketService::class.java) + try { + copyTradingWebSocketService.reconnectAll() + logger.info("已触发跟单 WebSocket 服务重连") + } catch (e: Exception) { + logger.error("触发跟单 WebSocket 服务重连失败", e) + } + } catch (e: BeansException) { + logger.debug("跟单 WebSocket 服务未找到,跳过重连", e) + } + } catch (e: Exception) { + logger.error("触发 WebSocket 重连失败", e) + } + } + + /** + * 转换为 DTO(不包含密码) + */ + private fun toDto(config: ProxyConfig): ProxyConfigDto { + return ProxyConfigDto( + id = config.id, + type = config.type, + enabled = config.enabled, + host = config.host, + port = config.port, + username = config.username, + subscriptionUrl = config.subscriptionUrl, + lastSubscriptionUpdate = config.lastSubscriptionUpdate, + createdAt = config.createdAt, + updatedAt = config.updatedAt + ) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt index be7b3af..469817f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt @@ -12,51 +12,44 @@ import javax.net.ssl.* /** * 获取代理配置(用于 WebSocket 和 HTTP 请求) + * 从数据库读取代理配置 * @return Proxy 对象,如果未启用代理则返回 null */ fun getProxyConfig(): Proxy? { - if (getEnv("ENABLE_PROXY") != "1") { - return null - } - val host = getEnv("PROXY_HOST").ifEmpty { "127.0.0.1" } - val port = getEnv("PROXY_PORT").toIntOrNull() ?: 8888 - return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port)) + return ProxyConfigProvider.getProxy() } /** * 创建OkHttpClient客户端 + * 自动应用代理配置(从数据库读取) * @return OkHttpClient.Builder */ -fun createClient() = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .httpProxy("127.0.0.1", 8888) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) +fun createClient(): OkHttpClient.Builder { + val builder = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) -/** - * 为OkHttpClient添加HTTP代理支持 - * @param hostname 代理服务器地址 - * @param port 代理服务器端口 - * @param user 代理用户名(可选) - * @param password 代理密码(可选) - * @return OkHttpClient.Builder - */ -fun OkHttpClient.Builder.httpProxy( - hostname: String, port: Int, user: String = "", password: String = "" -): OkHttpClient.Builder { - if (getEnv("ENABLE_PROXY") != "1") { - return this - } - return apply { - proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(hostname, port))) - createSSLSocketFactory() - if (user.isNotEmpty() && password.isNotEmpty()) { - proxyAuthenticator { _, res -> - val credential: String = Credentials.basic(user, password) - res.request.newBuilder().header("Proxy-Authorization", credential).build() + // 从数据库读取代理配置 + val dbProxy = ProxyConfigProvider.getProxy() + if (dbProxy != null) { + builder.proxy(dbProxy) + builder.createSSLSocketFactory() + + // 如果配置了用户名和密码,添加代理认证 + val username = ProxyConfigProvider.getProxyUsername() + val password = ProxyConfigProvider.getProxyPassword() + if (username != null && password != null) { + builder.proxyAuthenticator { _, response -> + val credential = Credentials.basic(username, password) + response.request.newBuilder() + .header("Proxy-Authorization", credential) + .build() } } } + + return builder } /** @@ -64,12 +57,15 @@ fun OkHttpClient.Builder.httpProxy( * @return OkHttpClient.Builder */ fun OkHttpClient.Builder.createSSLSocketFactory(): OkHttpClient.Builder { - runCatching { - val sc: SSLContext = SSLContext.getInstance("TLS") - sc.init(null, arrayOf(TrustAllManager()), SecureRandom()) - this.sslSocketFactory(sc.socketFactory, TrustAllManager()) + return apply { + try { + val sc: SSLContext = SSLContext.getInstance("TLS") + sc.init(null, arrayOf(TrustAllManager()), SecureRandom()) + sslSocketFactory(sc.socketFactory, TrustAllManager()) + } catch (t: Error) { + + } } - return this } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/ProxyConfigProvider.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/ProxyConfigProvider.kt new file mode 100644 index 0000000..09cbd29 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/ProxyConfigProvider.kt @@ -0,0 +1,54 @@ +package com.wrbug.polymarketbot.util + +import com.wrbug.polymarketbot.entity.ProxyConfig +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * 代理配置提供者(单例) + * 用于在工具函数中获取代理配置 + */ +object ProxyConfigProvider { + @Volatile + private var proxyConfig: ProxyConfig? = null + + /** + * 设置代理配置(由 ProxyConfigService 调用) + */ + fun setProxyConfig(config: ProxyConfig?) { + proxyConfig = config + } + + /** + * 获取代理配置 + */ + fun getProxyConfig(): ProxyConfig? = proxyConfig + + /** + * 获取 Proxy 对象(用于 OkHttp) + */ + fun getProxy(): Proxy? { + val config = proxyConfig ?: return null + if (!config.enabled) { + return null + } + if (config.type != "HTTP") { + return null // 目前只支持 HTTP 代理 + } + if (config.host == null || config.port == null) { + return null + } + return Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)) + } + + /** + * 获取代理用户名 + */ + fun getProxyUsername(): String? = proxyConfig?.username + + /** + * 获取代理密码 + */ + fun getProxyPassword(): String? = proxyConfig?.password +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt index 359f1b6..37e9584 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt @@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory /** * Polymarket WebSocket 客户端(使用 OkHttp 实现) * 用于连接到 Polymarket RTDS - * 支持代理配置(通过环境变量 ENABLE_PROXY、PROXY_HOST、PROXY_PORT 控制) + * 支持代理配置(从数据库读取) */ class PolymarketWebSocketClient( private val url: String, diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3a015ad..631a8b6 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -36,10 +36,10 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com polymarket.data-api.base-url=https://data-api.polymarket.com polymarket.gamma.base-url=https://gamma-api.polymarket.com -# Ethereum RPC 配置(用于查询链上余额) -# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓 +# Polygon RPC 配置(用于查询链上余额) +# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓 # 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID -ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com} +polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com} # 仓位推送配置 # 轮询间隔(毫秒),默认3秒 diff --git a/backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql b/backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql deleted file mode 100644 index b34c6ef..0000000 --- a/backend/src/main/resources/db/migration/V10__add_outcome_index_to_order_tracking.sql +++ /dev/null @@ -1,18 +0,0 @@ --- 添加 outcome_index 字段到订单跟踪表和卖出匹配记录表 --- 支持多元市场(不限于YES/NO) - --- 1. 添加 outcome_index 字段到 copy_order_tracking 表 -ALTER TABLE copy_order_tracking -ADD COLUMN outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场' AFTER side; - --- 2. 添加 outcome_index 字段到 sell_match_record 表 -ALTER TABLE sell_match_record -ADD COLUMN outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场' AFTER side; - --- 3. 添加索引以优化查询性能 -ALTER TABLE copy_order_tracking -ADD INDEX idx_market_outcome (market_id, outcome_index); - -ALTER TABLE sell_match_record -ADD INDEX idx_market_outcome (market_id, outcome_index); - diff --git a/backend/src/main/resources/db/migration/V11__create_users_table.sql b/backend/src/main/resources/db/migration/V11__create_users_table.sql deleted file mode 100644 index 036b101..0000000 --- a/backend/src/main/resources/db/migration/V11__create_users_table.sql +++ /dev/null @@ -1,10 +0,0 @@ --- 创建用户表(用于JWT登录鉴权) -CREATE TABLE IF NOT EXISTS users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(唯一)', - password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', - INDEX idx_username (username) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表(JWT登录鉴权)'; - diff --git a/backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql b/backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql deleted file mode 100644 index 8999f5c..0000000 --- a/backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 添加 is_default 字段到 users 表 -ALTER TABLE users -ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户(首次创建的用户)'; - --- 为默认账户添加索引 -ALTER TABLE users -ADD INDEX idx_is_default (is_default); - diff --git a/backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql b/backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql deleted file mode 100644 index 6356569..0000000 --- a/backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 创建账户表 -CREATE TABLE IF NOT EXISTS copy_trading_accounts ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)', - wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)', - api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)', - account_name VARCHAR(100) NULL COMMENT '账户名称', - is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', - INDEX idx_wallet_address (wallet_address), - INDEX idx_is_default (is_default) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表'; - diff --git a/backend/src/main/resources/db/migration/V1__init_database.sql b/backend/src/main/resources/db/migration/V1__init_database.sql new file mode 100644 index 0000000..85328ef --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init_database.sql @@ -0,0 +1,211 @@ +-- ============================================ +-- 数据库初始化脚本(合并所有迁移版本) +-- ============================================ + +-- ============================================ +-- 1. 创建账户表 +-- ============================================ +CREATE TABLE IF NOT EXISTS copy_trading_accounts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)', + wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)', + proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)', + api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)', + api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)', + api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)', + account_name VARCHAR(100) NULL COMMENT '账户名称', + is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_wallet_address (wallet_address), + INDEX idx_is_default (is_default) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表'; + +-- ============================================ +-- 2. 创建被跟单者(Leader)表 +-- ============================================ +CREATE TABLE IF NOT EXISTS copy_trading_leaders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + leader_address VARCHAR(42) NOT NULL UNIQUE COMMENT '被跟单者的钱包地址', + leader_name VARCHAR(100) NULL COMMENT '被跟单者名称', + category VARCHAR(20) NULL COMMENT '分类筛选(sports/crypto),null表示不筛选', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_leader_address (leader_address), + INDEX idx_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='被跟单者表'; + +-- ============================================ +-- 3. 创建跟单模板表 +-- ============================================ +CREATE TABLE IF NOT EXISTS copy_trading_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_name VARCHAR(100) NOT NULL UNIQUE COMMENT '模板名称', + copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单金额模式(RATIO/FIXED)', + copy_ratio DECIMAL(10, 2) NOT NULL DEFAULT 1.00 COMMENT '跟单比例(仅在copyMode=RATIO时生效)', + fixed_amount DECIMAL(20, 8) NULL COMMENT '固定跟单金额(仅在copyMode=FIXED时生效)', + max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.00000000 COMMENT '单笔订单最大金额(USDC)', + min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '单笔订单最小金额(USDC)', + max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.00000000 COMMENT '每日最大亏损限制(USDC)', + max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '每日最大跟单订单数', + price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.00 COMMENT '价格容忍度(百分比,0-100)', + delay_seconds INT NOT NULL DEFAULT 0 COMMENT '跟单延迟(秒,默认0立即跟单)', + poll_interval_seconds INT NOT NULL DEFAULT 5 COMMENT '轮询间隔(秒,仅在WebSocket不可用时使用)', + use_websocket BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否优先使用WebSocket推送', + websocket_reconnect_interval INT NOT NULL DEFAULT 5000 COMMENT 'WebSocket重连间隔(毫秒)', + websocket_max_retries INT NOT NULL DEFAULT 10 COMMENT 'WebSocket最大重试次数', + support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持跟单卖出', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_template_name (template_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单模板表'; + +-- ============================================ +-- 4. 创建跟单关系表(钱包-模板关联,多对多关系) +-- ============================================ +CREATE TABLE IF NOT EXISTS copy_trading ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + account_id BIGINT NOT NULL COMMENT '钱包账户ID', + template_id BIGINT NOT NULL COMMENT '模板ID', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + UNIQUE KEY uk_account_template_leader (account_id, template_id, leader_id), + INDEX idx_account_id (account_id), + INDEX idx_template_id (template_id), + INDEX idx_leader_id (leader_id), + INDEX idx_enabled (enabled), + FOREIGN KEY (account_id) REFERENCES copy_trading_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES copy_trading_templates(id) ON DELETE RESTRICT, + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单关系表(钱包-模板关联)'; + +-- ============================================ +-- 5. 创建订单跟踪表 +-- ============================================ +CREATE TABLE IF NOT EXISTS copy_order_tracking ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', + account_id BIGINT NOT NULL COMMENT '账户ID', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + template_id BIGINT NOT NULL COMMENT '模板ID', + market_id VARCHAR(100) NOT NULL COMMENT '市场地址', + side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', + outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场', + buy_order_id VARCHAR(100) NOT NULL COMMENT '跟单买入订单ID', + leader_buy_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 买入交易ID', + quantity DECIMAL(20, 8) NOT NULL COMMENT '买入数量', + price DECIMAL(20, 8) NOT NULL COMMENT '买入价格', + matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '已匹配卖出数量', + remaining_quantity DECIMAL(20, 8) NOT NULL COMMENT '剩余未匹配数量', + status VARCHAR(20) NOT NULL COMMENT '状态:filled, fully_matched, partially_matched', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_copy_trading (copy_trading_id), + INDEX idx_remaining (remaining_quantity, status), + INDEX idx_market_side (market_id, side), + INDEX idx_market_outcome (market_id, outcome_index), + INDEX idx_leader_trade (leader_id, leader_buy_trade_id), + FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单跟踪表'; + +-- ============================================ +-- 6. 创建卖出匹配记录表 +-- ============================================ +CREATE TABLE IF NOT EXISTS sell_match_record ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', + sell_order_id VARCHAR(100) NOT NULL COMMENT '跟单卖出订单ID', + leader_sell_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 卖出交易ID', + market_id VARCHAR(100) NOT NULL COMMENT '市场地址', + side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', + outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场', + total_matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '总匹配数量', + sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格', + total_realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '总已实现盈亏', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + INDEX idx_copy_trading (copy_trading_id), + INDEX idx_sell_order (sell_order_id), + INDEX idx_market_outcome (market_id, outcome_index), + INDEX idx_leader_trade (leader_sell_trade_id), + FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卖出匹配记录表'; + +-- ============================================ +-- 7. 创建匹配明细表 +-- ============================================ +CREATE TABLE IF NOT EXISTS sell_match_detail ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + match_record_id BIGINT NOT NULL COMMENT '关联 sell_match_record.id', + tracking_id BIGINT NOT NULL COMMENT '关联 copy_order_tracking.id', + buy_order_id VARCHAR(100) NOT NULL COMMENT '买入订单ID', + matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '匹配的数量', + buy_price DECIMAL(20, 8) NOT NULL COMMENT '买入价格', + sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格', + realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '盈亏 = (sell_price - buy_price) * matched_quantity', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + INDEX idx_match_record (match_record_id), + INDEX idx_tracking (tracking_id), + INDEX idx_buy_order (buy_order_id), + FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id) ON DELETE CASCADE, + FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配明细表'; + +-- ============================================ +-- 8. 创建已处理交易表(用于去重) +-- ============================================ +CREATE TABLE IF NOT EXISTS processed_trade ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID(trade.id,唯一标识)', + trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL', + source VARCHAR(20) NOT NULL COMMENT '数据来源:websocket 或 polling', + status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS' COMMENT '处理状态:SUCCESS(成功)、FAILED(失败)', + processed_at BIGINT NOT NULL COMMENT '处理时间(毫秒时间戳)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id), + INDEX idx_processed_at (processed_at), + INDEX idx_leader_id (leader_id), + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已处理交易表(用于去重)'; + +-- ============================================ +-- 9. 创建失败交易记录表 +-- ============================================ +CREATE TABLE IF NOT EXISTS failed_trade ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID', + trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL', + copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', + account_id BIGINT NOT NULL COMMENT '账户ID', + market_id VARCHAR(100) NOT NULL COMMENT '市场地址', + side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', + price VARCHAR(50) NOT NULL COMMENT '价格', + size VARCHAR(50) NOT NULL COMMENT '数量', + error_message TEXT COMMENT '错误信息', + retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + failed_at BIGINT NOT NULL COMMENT '失败时间(毫秒时间戳)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + INDEX idx_leader_trade (leader_id, leader_trade_id), + INDEX idx_copy_trading (copy_trading_id), + INDEX idx_failed_at (failed_at), + FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE, + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失败交易记录表'; + +-- ============================================ +-- 10. 创建用户表(用于JWT登录鉴权) +-- ============================================ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(唯一)', + password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)', + is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户(首次创建的用户)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_username (username), + INDEX idx_is_default (is_default) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表(JWT登录鉴权)'; diff --git a/backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql b/backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql deleted file mode 100644 index 8acdcad..0000000 --- a/backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 添加 API Secret 和 Passphrase 字段 -ALTER TABLE copy_trading_accounts -ADD COLUMN api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)' AFTER api_key, -ADD COLUMN api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)' AFTER api_secret; - diff --git a/backend/src/main/resources/db/migration/V2__create_proxy_config_table.sql b/backend/src/main/resources/db/migration/V2__create_proxy_config_table.sql new file mode 100644 index 0000000..601446d --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__create_proxy_config_table.sql @@ -0,0 +1,20 @@ +-- ============================================ +-- 创建代理配置表 +-- ============================================ +CREATE TABLE IF NOT EXISTS proxy_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(20) NOT NULL COMMENT '代理类型:HTTP, CLASH, SS', + enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用', + host VARCHAR(255) NULL COMMENT '代理主机(HTTP代理)', + port INT NULL COMMENT '代理端口(HTTP代理)', + username VARCHAR(100) NULL COMMENT '代理用户名(HTTP代理,可选)', + password VARCHAR(255) NULL COMMENT '代理密码(HTTP代理,可选,BCrypt加密)', + subscription_url VARCHAR(500) NULL COMMENT '订阅链接(Clash/SS代理)', + subscription_config TEXT NULL COMMENT '订阅配置内容(Clash/SS代理,JSON格式)', + last_subscription_update BIGINT NULL COMMENT '最后订阅更新时间(毫秒时间戳)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_type (type), + INDEX idx_enabled (enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代理配置表'; + diff --git a/backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql b/backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql deleted file mode 100644 index 314448b..0000000 --- a/backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 添加代理地址字段到账户表 -ALTER TABLE copy_trading_accounts -ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address; - diff --git a/backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql b/backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql deleted file mode 100644 index d4553ff..0000000 --- a/backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 添加是否启用字段到账户表 -ALTER TABLE copy_trading_accounts -ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)' AFTER is_default; - diff --git a/backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql b/backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql deleted file mode 100644 index fcbd644..0000000 --- a/backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 创建被跟单者(Leader)表 -CREATE TABLE IF NOT EXISTS copy_trading_leaders ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - leader_address VARCHAR(42) NOT NULL UNIQUE COMMENT '被跟单者的钱包地址', - leader_name VARCHAR(100) NULL COMMENT '被跟单者名称', - category VARCHAR(20) NULL COMMENT '分类筛选(sports/crypto),null表示不筛选', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', - INDEX idx_leader_address (leader_address), - INDEX idx_category (category) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='被跟单者表'; - diff --git a/backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql b/backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql deleted file mode 100644 index 49c0a12..0000000 --- a/backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 创建跟单模板表 -CREATE TABLE IF NOT EXISTS copy_trading_templates ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - template_name VARCHAR(100) NOT NULL UNIQUE COMMENT '模板名称', - copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单金额模式(RATIO/FIXED)', - copy_ratio DECIMAL(10, 2) NOT NULL DEFAULT 1.00 COMMENT '跟单比例(仅在copyMode=RATIO时生效)', - fixed_amount DECIMAL(20, 8) NULL COMMENT '固定跟单金额(仅在copyMode=FIXED时生效)', - max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.00000000 COMMENT '单笔订单最大金额(USDC)', - min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '单笔订单最小金额(USDC)', - max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.00000000 COMMENT '每日最大亏损限制(USDC)', - max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '每日最大跟单订单数', - price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.00 COMMENT '价格容忍度(百分比,0-100)', - delay_seconds INT NOT NULL DEFAULT 0 COMMENT '跟单延迟(秒,默认0立即跟单)', - poll_interval_seconds INT NOT NULL DEFAULT 5 COMMENT '轮询间隔(秒,仅在WebSocket不可用时使用)', - use_websocket BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否优先使用WebSocket推送', - websocket_reconnect_interval INT NOT NULL DEFAULT 5000 COMMENT 'WebSocket重连间隔(毫秒)', - websocket_max_retries INT NOT NULL DEFAULT 10 COMMENT 'WebSocket最大重试次数', - support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持跟单卖出', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', - INDEX idx_template_name (template_name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单模板表'; - diff --git a/backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql b/backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql deleted file mode 100644 index 6672a31..0000000 --- a/backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 创建跟单关系表(钱包-模板关联,多对多关系) -CREATE TABLE IF NOT EXISTS copy_trading ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - account_id BIGINT NOT NULL COMMENT '钱包账户ID', - template_id BIGINT NOT NULL COMMENT '模板ID', - leader_id BIGINT NOT NULL COMMENT 'Leader ID', - enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', - UNIQUE KEY uk_account_template_leader (account_id, template_id, leader_id), - INDEX idx_account_id (account_id), - INDEX idx_template_id (template_id), - INDEX idx_leader_id (leader_id), - INDEX idx_enabled (enabled), - FOREIGN KEY (account_id) REFERENCES copy_trading_accounts(id) ON DELETE CASCADE, - FOREIGN KEY (template_id) REFERENCES copy_trading_templates(id) ON DELETE RESTRICT, - FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单关系表(钱包-模板关联)'; - diff --git a/backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql b/backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql deleted file mode 100644 index 1f00067..0000000 --- a/backend/src/main/resources/db/migration/V8__create_order_tracking_tables.sql +++ /dev/null @@ -1,76 +0,0 @@ --- 创建订单跟踪表 -CREATE TABLE IF NOT EXISTS copy_order_tracking ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', - account_id BIGINT NOT NULL COMMENT '账户ID', - leader_id BIGINT NOT NULL COMMENT 'Leader ID', - template_id BIGINT NOT NULL COMMENT '模板ID', - market_id VARCHAR(100) NOT NULL COMMENT '市场地址', - side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', - buy_order_id VARCHAR(100) NOT NULL COMMENT '跟单买入订单ID', - leader_buy_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 买入交易ID', - quantity DECIMAL(20, 8) NOT NULL COMMENT '买入数量', - price DECIMAL(20, 8) NOT NULL COMMENT '买入价格', - matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '已匹配卖出数量', - remaining_quantity DECIMAL(20, 8) NOT NULL COMMENT '剩余未匹配数量', - status VARCHAR(20) NOT NULL COMMENT '状态:filled, fully_matched, partially_matched', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', - INDEX idx_copy_trading (copy_trading_id), - INDEX idx_remaining (remaining_quantity, status), - INDEX idx_market_side (market_id, side), - INDEX idx_leader_trade (leader_id, leader_buy_trade_id), - FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单跟踪表'; - --- 创建卖出匹配记录表 -CREATE TABLE IF NOT EXISTS sell_match_record ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', - sell_order_id VARCHAR(100) NOT NULL COMMENT '跟单卖出订单ID', - leader_sell_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 卖出交易ID', - market_id VARCHAR(100) NOT NULL COMMENT '市场地址', - side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', - total_matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '总匹配数量', - sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格', - total_realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '总已实现盈亏', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - INDEX idx_copy_trading (copy_trading_id), - INDEX idx_sell_order (sell_order_id), - INDEX idx_leader_trade (leader_sell_trade_id), - FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卖出匹配记录表'; - --- 创建匹配明细表 -CREATE TABLE IF NOT EXISTS sell_match_detail ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - match_record_id BIGINT NOT NULL COMMENT '关联 sell_match_record.id', - tracking_id BIGINT NOT NULL COMMENT '关联 copy_order_tracking.id', - buy_order_id VARCHAR(100) NOT NULL COMMENT '买入订单ID', - matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '匹配的数量', - buy_price DECIMAL(20, 8) NOT NULL COMMENT '买入价格', - sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格', - realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '盈亏 = (sell_price - buy_price) * matched_quantity', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - INDEX idx_match_record (match_record_id), - INDEX idx_tracking (tracking_id), - INDEX idx_buy_order (buy_order_id), - FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id) ON DELETE CASCADE, - FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配明细表'; - --- 创建已处理交易表(用于去重) -CREATE TABLE IF NOT EXISTS processed_trade ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - leader_id BIGINT NOT NULL COMMENT 'Leader ID', - leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID(trade.id,唯一标识)', - trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL', - source VARCHAR(20) NOT NULL COMMENT '数据来源:websocket 或 polling', - processed_at BIGINT NOT NULL COMMENT '处理时间(毫秒时间戳)', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id), - INDEX idx_processed_at (processed_at), - INDEX idx_leader_id (leader_id), - FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已处理交易表(用于去重)'; - diff --git a/backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql b/backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql deleted file mode 100644 index 70e8a87..0000000 --- a/backend/src/main/resources/db/migration/V9__add_failed_trade_tracking.sql +++ /dev/null @@ -1,27 +0,0 @@ --- 修改已处理交易表,添加状态字段 -ALTER TABLE processed_trade -ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS' COMMENT '处理状态:SUCCESS(成功)、FAILED(失败)' AFTER source; - --- 创建失败交易记录表 -CREATE TABLE IF NOT EXISTS failed_trade ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - leader_id BIGINT NOT NULL COMMENT 'Leader ID', - leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID', - trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL', - copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID', - account_id BIGINT NOT NULL COMMENT '账户ID', - market_id VARCHAR(100) NOT NULL COMMENT '市场地址', - side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO', - price VARCHAR(50) NOT NULL COMMENT '价格', - size VARCHAR(50) NOT NULL COMMENT '数量', - error_message TEXT COMMENT '错误信息', - retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', - failed_at BIGINT NOT NULL COMMENT '失败时间(毫秒时间戳)', - created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', - INDEX idx_leader_trade (leader_id, leader_trade_id), - INDEX idx_copy_trading (copy_trading_id), - INDEX idx_failed_at (failed_at), - FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE, - FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失败交易记录表'; - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 313d360..8aa161e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import CopyTradingStatistics from './pages/CopyTradingStatistics' import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders' import CopyTradingSellOrders from './pages/CopyTradingSellOrders' import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' +import SystemSettings from './pages/SystemSettings' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -226,6 +227,7 @@ function App() { } /> } /> } /> + } /> {/* 默认重定向到登录页 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 27ff2ef..007fe3b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,7 +12,8 @@ import { LinkOutlined, AppstoreOutlined, TeamOutlined, - LogoutOutlined + LogoutOutlined, + SettingOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -101,6 +102,11 @@ const Layout: React.FC = ({ children }) => { icon: , label: '用户管理' }, + { + key: '/system-settings', + icon: , + label: '系统管理' + }, { key: 'logout', icon: , diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx new file mode 100644 index 0000000..694d8b3 --- /dev/null +++ b/frontend/src/pages/SystemSettings.tsx @@ -0,0 +1,397 @@ +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 { apiService } from '../services/api' +import { useMediaQuery } from 'react-responsive' + +const { Title, Text } = Typography + +interface ProxyConfig { + id?: number + type: string + enabled: boolean + host?: string + port?: number + username?: string + subscriptionUrl?: string + lastSubscriptionUpdate?: number + createdAt: number + updatedAt: number +} + +interface ProxyCheckResponse { + success: boolean + message: string + responseTime?: number + latency?: number // 延迟(毫秒) +} + +interface ApiHealthStatus { + name: string + url: string + status: string + message: string + responseTime?: number +} + +const SystemSettings: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [form] = 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) + + useEffect(() => { + fetchConfig() + checkApiHealth() + }, []) + + const fetchConfig = async () => { + try { + const response = await apiService.proxyConfig.get() + if (response.data.code === 0) { + const data = response.data.data + setCurrentConfig(data) + if (data) { + form.setFieldsValue({ + enabled: data.enabled, + host: data.host || '', + port: data.port || undefined, + username: data.username || '', + password: '', // 密码不预填充 + }) + } else { + form.resetFields() + } + } else { + message.error(response.data.msg || '获取代理配置失败') + } + } catch (error: any) { + message.error(error.message || '获取代理配置失败') + } + } + + const handleSubmit = async (values: any) => { + setLoading(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, // 如果密码为空,则不更新密码 + }) + if (response.data.code === 0) { + message.success('保存代理配置成功。新配置将立即生效,已建立的 WebSocket 连接需要重新连接才能使用新代理。') + fetchConfig() + setCheckResult(null) // 清除检查结果 + // 自动刷新 API 健康状态,验证新配置是否生效 + setTimeout(() => { + checkApiHealth() + }, 1000) + } else { + message.error(response.data.msg || '保存代理配置失败') + } + } catch (error: any) { + message.error(error.message || '保存代理配置失败') + } finally { + setLoading(false) + } + } + + const handleCheck = async () => { + setChecking(true) + setCheckResult(null) + try { + const response = await apiService.proxyConfig.check() + if (response.data.code === 0 && response.data.data) { + const result = response.data.data + setCheckResult(result) + if (result.success) { + message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`) + } else { + message.warning(`代理检查失败:${result.message}`) + } + } else { + message.error(response.data.msg || '代理检查失败') + } + } 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 '异常' + } + } + + return ( +
+
+ 系统管理 +
+ + + + API 健康状态 + + } + style={{ marginBottom: '16px' }} + extra={ + + } + > + + + {apiHealthStatus.map((item, index) => ( + + {isMobile ? ( + // 移动端:一行显示,只保留名称、延迟、状态点(不显示文字) + +
+ + {item.name} + + + {item.responseTime !== undefined && item.responseTime !== null && ( + + {item.responseTime}ms + + )} + + +
+
+ ) : ( + // 桌面端:保持原有卡片布局 + + +
+ + {item.name} + + +
+ +
+ + {item.url} + +
+ + {item.message && item.message !== '连接成功' && ( +
+ + {item.message} + +
+ )} + + {item.responseTime !== undefined && item.responseTime !== null && ( +
+ + 延迟: {item.responseTime}ms + +
+ )} +
+
+ )} + + ))} +
+ + {apiHealthStatus.length === 0 && !checkingApiHealth && ( +
+ 暂无 API 状态信息 +
+ )} +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + {checkResult && ( + + )} + + +
+ + {checkResult && ( + + {checkResult.message} + {(checkResult.responseTime !== undefined || checkResult.latency !== undefined) && ( +
+ + 延迟: {(checkResult.latency ?? checkResult.responseTime) ?? 0}ms + +
+ )} +
+ } + style={{ marginTop: '16px' }} + showIcon + /> + )} + + + ) +} + +export default SystemSettings + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9971ec7..0c541f5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -387,6 +387,65 @@ export const apiService = { */ list: (data: any) => apiClient.post>('/copy-trading/orders/tracking', data) + }, + + /** + * 代理配置 API + */ + proxyConfig: { + /** + * 获取当前代理配置 + */ + get: () => + apiClient.post>('/proxy-config/get', {}), + + /** + * 获取所有代理配置 + */ + list: () => + apiClient.post>('/proxy-config/list', {}), + + /** + * 保存 HTTP 代理配置 + */ + saveHttp: (data: { + enabled: boolean + host: string + port: number + username?: string + password?: string + }) => + apiClient.post>('/proxy-config/http/save', data), + + /** + * 检查代理是否可用 + */ + check: () => + apiClient.post>('/proxy-config/check', {}), + + /** + * 删除代理配置 + */ + delete: (data: { id: number }) => + apiClient.post>('/proxy-config/delete', data), + + /** + * 检查所有 API 的健康状态 + */ + checkApiHealth: () => + apiClient.post + }>>('/proxy-config/api-health-check', {}) } }