diff --git a/.gitignore b/.gitignore index e526be3..4d4b0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,5 @@ __pycache__/ # Submodules and external dependencies clob-client/ builder-relayer-client/ +landing-page/ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/RpcNodeController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/RpcNodeController.kt new file mode 100644 index 0000000..3d202b6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/RpcNodeController.kt @@ -0,0 +1,366 @@ +package com.wrbug.polymarketbot.controller.system + +import com.wrbug.polymarketbot.dto.ApiResponse +import com.wrbug.polymarketbot.entity.RpcNodeConfig +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.system.AddRpcNodeRequest +import com.wrbug.polymarketbot.service.system.NodeCheckResult +import com.wrbug.polymarketbot.service.system.RpcNodeService +import com.wrbug.polymarketbot.service.system.UpdateRpcNodeRequest +import org.slf4j.LoggerFactory +import org.springframework.context.MessageSource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * RPC 节点管理控制器 + */ +@RestController +@RequestMapping("/api/system/rpc-nodes") +class RpcNodeController( + private val rpcNodeService: RpcNodeService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(RpcNodeController::class.java) + + /** + * 获取所有节点列表 + */ + @PostMapping("/list") + fun getAllNodes(@RequestBody request: Map?): ResponseEntity>> { + return try { + val nodes = rpcNodeService.getAllNodes() + val dtos = nodes.map { it.toDto() } + ResponseEntity.ok(ApiResponse.success(dtos)) + } catch (e: Exception) { + logger.error("获取 RPC 节点列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "获取节点列表失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 添加节点 + */ + @PostMapping("/add") + fun addNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity> { + return try { + if (request.providerType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空")) + } + if (request.name.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空")) + } + + val result = rpcNodeService.addNode(request) + + result.fold( + onSuccess = { node -> + ResponseEntity.ok(ApiResponse.success(node.toDto())) + }, + onFailure = { e -> + logger.error("添加 RPC 节点失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "添加节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("添加 RPC 节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "添加节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 更新节点 + */ + @PostMapping("/update") + fun updateNode(@RequestBody request: UpdateRpcNodeRequest): ResponseEntity> { + return try { + val result = rpcNodeService.updateNode(request) + + result.fold( + onSuccess = { node -> + ResponseEntity.ok(ApiResponse.success(node.toDto())) + }, + onFailure = { e -> + logger.error("更新 RPC 节点失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("更新 RPC 节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 删除节点 + */ + @PostMapping("/delete") + fun deleteNode(@RequestBody request: DeleteRpcNodeRequest): ResponseEntity> { + return try { + val result = rpcNodeService.deleteNode(request.id) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除 RPC 节点失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "删除节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("删除 RPC 节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "删除节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 更新节点优先级 + */ + @PostMapping("/update-priority") + fun updatePriority(@RequestBody request: UpdatePriorityRequest): ResponseEntity> { + return try { + val result = rpcNodeService.updatePriority(request.id, request.priority) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("更新节点优先级失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新优先级失败:${e.message}", + messageSource = messageSource + )) + } + ) + } catch (e: Exception) { + logger.error("更新节点优先级异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "更新优先级失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 检查节点健康状态 + */ + @PostMapping("/check-health") + fun checkHealth(@RequestBody request: CheckHealthRequest): ResponseEntity> { + return try { + if (request.id != null) { + // 检查单个节点 + val result = rpcNodeService.checkNodeHealth(request.id) + result.fold( + onSuccess = { checkResult -> + ResponseEntity.ok(ApiResponse.success(checkResult.toDto())) + }, + onFailure = { e -> + logger.error("检查节点健康状态失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "检查节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } else { + // 批量检查所有节点 + val result = rpcNodeService.checkAllNodesHealth() + result.fold( + onSuccess = { checkResults -> + val dtos = checkResults.mapValues { it.value.toDto() } + ResponseEntity.ok(ApiResponse.success(dtos)) + }, + onFailure = { e -> + logger.error("批量检查节点健康状态失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "批量检查节点失败:${e.message}", + messageSource = messageSource + )) + } + ) + } + } catch (e: Exception) { + logger.error("检查节点健康状态异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.SERVER_ERROR, + customMsg = "检查节点失败:${e.message}", + messageSource = messageSource + )) + } + } + + /** + * 校验节点(添加前) + */ + @PostMapping("/validate") + fun validateNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity> { + return try { + if (request.providerType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空")) + } + if (request.name.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空")) + } + + // 临时创建节点配置以进行验证(不保存到数据库) + // 这里直接复用 addNode 的部分逻辑,但只进行校验 + val result = rpcNodeService.addNode(request) + + result.fold( + onSuccess = { node -> + // 添加成功后立即删除(这只是为了校验) + rpcNodeService.deleteNode(node.id!!) + ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse( + valid = true, + message = "节点可用", + responseTimeMs = node.responseTimeMs + ))) + }, + onFailure = { e -> + ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse( + valid = false, + message = e.message ?: "节点验证失败", + responseTimeMs = null + ))) + } + ) + } catch (e: Exception) { + logger.error("验证节点异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse( + valid = false, + message = e.message ?: "节点验证失败", + responseTimeMs = null + ))) + } + } +} + +/** + * RPC 节点配置 DTO + */ +data class RpcNodeConfigDto( + val id: Long?, + val providerType: String, + val name: String, + val httpUrl: String, + val wsUrl: String?, + val apiKeyMasked: String?, // 脱敏后的 API Key + val enabled: Boolean, + val priority: Int, + val lastCheckTime: Long?, + val lastCheckStatus: String?, + val responseTimeMs: Int?, + val createdAt: Long, + val updatedAt: Long +) + +/** + * 节点检查结果 DTO + */ +data class NodeCheckResultDto( + val status: String, + val message: String, + val checkTime: Long, + val responseTimeMs: Int?, + val blockNumber: String? +) + +/** + * 验证节点响应 + */ +data class ValidateNodeResponse( + val valid: Boolean, + val message: String, + val responseTimeMs: Int? +) + +/** + * 删除节点请求 + */ +data class DeleteRpcNodeRequest( + val id: Long +) + +/** + * 更新优先级请求 + */ +data class UpdatePriorityRequest( + val id: Long, + val priority: Int +) + +/** + * 检查健康状态请求 + */ +data class CheckHealthRequest( + val id: Long? = null // 如果为 null,则检查所有节点 +) + +/** + * 扩展函数:将 RpcNodeConfig 转换为 DTO + */ +private fun RpcNodeConfig.toDto(): RpcNodeConfigDto { + return RpcNodeConfigDto( + id = id, + providerType = providerType, + name = name, + httpUrl = httpUrl, + wsUrl = wsUrl, + apiKeyMasked = apiKey?.let { "***" }, // 脱敏显示 + enabled = enabled, + priority = priority, + lastCheckTime = lastCheckTime, + lastCheckStatus = lastCheckStatus, + responseTimeMs = responseTimeMs, + createdAt = createdAt, + updatedAt = updatedAt + ) +} + +/** + * 扩展函数:将 NodeCheckResult 转换为 DTO + */ +private fun NodeCheckResult.toDto(): NodeCheckResultDto { + return NodeCheckResultDto( + status = status.name, + message = message, + checkTime = checkTime, + responseTimeMs = responseTimeMs, + blockNumber = blockNumber + ) +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/RpcNodeConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/RpcNodeConfig.kt new file mode 100644 index 0000000..8443207 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/RpcNodeConfig.kt @@ -0,0 +1,73 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * Polygon RPC 节点配置实体 + * 用于存储用户配置的 RPC 节点信息 + */ +@Entity +@Table(name = "rpc_node_config") +data class RpcNodeConfig( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "provider_type", nullable = false, length = 50) + val providerType: String, // 服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC + + @Column(name = "name", nullable = false, length = 100) + val name: String, // 节点名称 + + @Column(name = "http_url", nullable = false, length = 500) + val httpUrl: String, // HTTP RPC URL + + @Column(name = "ws_url", length = 500) + val wsUrl: String? = null, // WebSocket URL (可选) + + @Column(name = "api_key", length = 200) + val apiKey: String? = null, // API Key (加密存储) + + @Column(name = "enabled", nullable = false) + var enabled: Boolean = true, // 是否启用 + + @Column(name = "priority", nullable = false) + var priority: Int = 0, // 优先级(数字越小优先级越高) + + @Column(name = "last_check_time") + var lastCheckTime: Long? = null, // 最后检查时间(毫秒时间戳) + + @Column(name = "last_check_status", length = 20) + var lastCheckStatus: String? = null, // 最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN + + @Column(name = "response_time_ms") + var responseTimeMs: Int? = null, // 最后一次响应时间(毫秒) + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + +/** + * RPC 节点健康状态枚举 + */ +enum class NodeHealthStatus { + HEALTHY, // 健康 + UNHEALTHY, // 不健康 + UNKNOWN // 未知 +} + +/** + * RPC 节点服务商类型枚举 + */ +enum class RpcProviderType { + ALCHEMY, + INFURA, + QUICKNODE, + CHAINSTACK, + GETBLOCK, + CUSTOM, + PUBLIC +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/RpcNodeConfigRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/RpcNodeConfigRepository.kt new file mode 100644 index 0000000..c36f5c9 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/RpcNodeConfigRepository.kt @@ -0,0 +1,23 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.RpcNodeConfig +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface RpcNodeConfigRepository : JpaRepository { + /** + * 查询所有已启用的节点,按优先级排序(优先级数字越小越靠前) + */ + fun findAllByEnabledTrueOrderByPriorityAsc(): List + + /** + * 查询指定 ID 的已启用节点 + */ + fun findByIdAndEnabledTrue(id: Long): RpcNodeConfig? + + /** + * 查询所有节点,按优先级排序 + */ + fun findAllByOrderByPriorityAsc(): List +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index d3513a7..1db0eec 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -12,6 +12,7 @@ import com.wrbug.polymarketbot.util.createClient import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import com.wrbug.polymarketbot.service.system.RelayClientService +import com.wrbug.polymarketbot.service.system.RpcNodeService import org.springframework.stereotype.Service import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -26,10 +27,9 @@ import java.math.BigInteger class BlockchainService( @Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}") private val dataApiBaseUrl: String, - @Value("\${polygon.rpc.url:}") - private val polygonRpcUrl: String, private val retrofitFactory: RetrofitFactory, - private val relayClientService: RelayClientService + private val relayClientService: RelayClientService, + private val rpcNodeService: RpcNodeService ) { private val logger = LoggerFactory.getLogger(BlockchainService::class.java) @@ -69,12 +69,9 @@ class BlockchainService( .create(PolymarketDataApi::class.java) } - private val polygonRpcApi: EthereumRpcApi? by lazy { - if (polygonRpcUrl.isBlank()) { - null - } else { - retrofitFactory.createEthereumRpcApi(polygonRpcUrl) - } + private val polygonRpcApi: EthereumRpcApi by lazy { + val rpcUrl = rpcNodeService.getHttpUrl() + retrofitFactory.createEthereumRpcApi(rpcUrl) } /** @@ -85,13 +82,7 @@ class BlockchainService( */ suspend fun getProxyAddress(walletAddress: String): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法获取代理地址") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法获取代理地址。请在配置文件中设置 polygon.rpc.url 环境变量")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 计算函数选择器 val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature) @@ -147,12 +138,6 @@ class BlockchainService( */ suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法查询 USDC 余额") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法查询 USDC 余额。请在配置文件中设置 polygon.rpc.url 环境变量")) - } - // 检查代理地址是否为空 if (proxyAddress.isBlank()) { logger.error("代理地址为空,无法查询余额") @@ -173,7 +158,7 @@ class BlockchainService( * 通过 RPC 查询 USDC 余额 */ private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 构建 ERC-20 balanceOf 函数调用 // function signature: balanceOf(address) -> bytes4(0x70a08231) @@ -264,13 +249,7 @@ class BlockchainService( */ suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法计算 tokenId") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法计算 tokenId")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 验证 outcomeIndex if (outcomeIndex < 0) { @@ -449,7 +428,7 @@ class BlockchainService( * 获取代理钱包的 nonce(用于构建 Safe 交易) */ private suspend fun getProxyNonce(proxyAddress: String): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取 val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()") @@ -484,7 +463,7 @@ class BlockchainService( * 获取交易 nonce */ private suspend fun getTransactionCount(address: String): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi val rpcRequest = JsonRpcRequest( method = "eth_getTransactionCount", @@ -512,7 +491,7 @@ class BlockchainService( * 获取 gas price */ private suspend fun getGasPrice(): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi val rpcRequest = JsonRpcRequest( method = "eth_gasPrice", @@ -614,11 +593,7 @@ class BlockchainService( */ suspend fun getTransactionDetails(txHash: String): Result { return try { - if (polygonRpcUrl.isBlank()) { - return Result.failure(IllegalStateException("未配置 Polygon RPC URL")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 查询交易 val txRequest = JsonRpcRequest( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt index e619c80..fe8e44b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt @@ -33,7 +33,8 @@ class ApiHealthCheckService( @Value("\${polymarket.rtds.ws-url}") private val polymarketWsUrl: String, @Value("\${polymarket.builder.relayer-url:}") - private val builderRelayerUrl: String + private val builderRelayerUrl: String, + private val rpcNodeService: RpcNodeService ) : ApplicationContextAware { private var applicationContext: ApplicationContext? = null @@ -186,19 +187,28 @@ class ApiHealthCheckService( /** * 检查 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 rpcUrl = try { + rpcNodeService.getHttpUrl() + } catch (e: Exception) { + logger.debug("获取可用 RPC 节点失败,使用配置的默认值: ${e.message}") + // 如果获取失败,使用配置的默认值作为兜底 + if (polygonRpcUrl.isNotBlank()) { + polygonRpcUrl + } else { + return@withContext ApiHealthCheckDto( + name = "Polygon RPC", + url = "未配置", + status = "skipped", + message = "未配置 Polygon RPC URL 且没有可用的节点" + ) + } } - - val url = polygonRpcUrl - checkJsonRpcApi("Polygon RPC", url) + + checkJsonRpcApi("Polygon RPC", rpcUrl) } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt index 8c30a59..53fc696 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt @@ -24,12 +24,11 @@ import java.math.BigInteger */ @Service class RelayClientService( - @Value("\${polygon.rpc.url:}") - private val polygonRpcUrl: String, @Value("\${polymarket.builder.relayer-url:}") private val builderRelayerUrl: String, private val retrofitFactory: RetrofitFactory, - private val systemConfigService: SystemConfigService + private val systemConfigService: SystemConfigService, + private val rpcNodeService: RpcNodeService ) { private val logger = LoggerFactory.getLogger(RelayClientService::class.java) @@ -43,12 +42,9 @@ class RelayClientService( // 空集合ID private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000" - private val polygonRpcApi: EthereumRpcApi? by lazy { - if (polygonRpcUrl.isBlank()) { - null - } else { - retrofitFactory.createEthereumRpcApi(polygonRpcUrl) - } + private val polygonRpcApi: EthereumRpcApi by lazy { + val rpcUrl = rpcNodeService.getHttpUrl() + retrofitFactory.createEthereumRpcApi(rpcUrl) } /** @@ -260,7 +256,7 @@ class RelayClientService( builderSecret: String, builderPassphrase: String ): Result { - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi val relayerApi = retrofitFactory.createBuilderRelayerApi( relayerUrl = builderRelayerUrl, apiKey = builderApiKey, @@ -461,13 +457,7 @@ class RelayClientService( safeTx: SafeTransaction ): Result { return try { - // 如果未配置 RPC URL,返回错误 - if (polygonRpcUrl.isBlank()) { - logger.warn("未配置 Polygon RPC URL,无法执行交易") - return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder Relayer(Gasless)")) - } - - val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置") + val rpcApi = polygonRpcApi // 从私钥推导实际签名地址(交易真正的 from 地址) val cleanPrivateKey = privateKey.removePrefix("0x") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt new file mode 100644 index 0000000..29b9888 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RpcNodeService.kt @@ -0,0 +1,509 @@ +package com.wrbug.polymarketbot.service.system + +import com.wrbug.polymarketbot.api.EthereumRpcApi +import com.wrbug.polymarketbot.api.JsonRpcRequest +import com.wrbug.polymarketbot.entity.NodeHealthStatus +import com.wrbug.polymarketbot.entity.RpcNodeConfig +import com.wrbug.polymarketbot.entity.RpcProviderType +import com.wrbug.polymarketbot.repository.RpcNodeConfigRepository +import com.wrbug.polymarketbot.util.CryptoUtils +import com.wrbug.polymarketbot.util.RetrofitFactory +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * RPC 节点管理服务 + * 负责管理用户配置的 Polygon RPC 节点 + */ +@Service +class RpcNodeService( + private val rpcNodeConfigRepository: RpcNodeConfigRepository, + private val cryptoUtils: CryptoUtils, + private val retrofitFactory: RetrofitFactory +) { + + private val logger = LoggerFactory.getLogger(RpcNodeService::class.java) + + companion object { + // 默认公共节点 + private const val DEFAULT_RPC_URL = "https://polygon.publicnode.com" + private const val DEFAULT_WS_URL = "wss://polygon.publicnode.com" + + // 主流服务商 URL 模板 + private val PROVIDER_HTTP_TEMPLATES = mapOf( + RpcProviderType.ALCHEMY to "https://polygon-mainnet.g.alchemy.com/v2/{apiKey}", + RpcProviderType.INFURA to "https://polygon-mainnet.infura.io/v3/{apiKey}", + RpcProviderType.QUICKNODE to "https://your-endpoint.quiknode.pro/{apiKey}/", + RpcProviderType.CHAINSTACK to "https://polygon-mainnet.core.chainstack.com/{apiKey}", + RpcProviderType.GETBLOCK to "https://go.getblock.io/{apiKey}/" + ) + + private val PROVIDER_WS_TEMPLATES = mapOf( + RpcProviderType.ALCHEMY to "wss://polygon-mainnet.g.alchemy.com/v2/{apiKey}", + RpcProviderType.INFURA to "wss://polygon-mainnet.infura.io/ws/v3/{apiKey}", + RpcProviderType.QUICKNODE to "wss://your-endpoint.quiknode.pro/{apiKey}/", + RpcProviderType.CHAINSTACK to "wss://ws-polygon-mainnet.core.chainstack.com/{apiKey}", + RpcProviderType.GETBLOCK to "wss://go.getblock.io/{apiKey}/" + ) + } + + /** + * 获取所有节点配置(不包含默认节点) + * 默认节点作为兜底,不应该返回给前端 + */ + fun getAllNodes(): List { + val allNodes = rpcNodeConfigRepository.findAllByOrderByPriorityAsc() + + // 过滤掉默认节点,只返回用户配置的节点 + return allNodes.filterNot { isDefaultNode(it) } + } + + /** + * 获取所有节点配置(包含默认节点,用于内部使用) + * 默认节点始终排在最后 + */ + fun getAllNodesWithDefault(): List { + val allNodes = rpcNodeConfigRepository.findAllByOrderByPriorityAsc() + + // 分离默认节点和用户配置的节点 + val (defaultNodes, userNodes) = allNodes.partition { isDefaultNode(it) } + + // 返回用户配置的节点,默认节点排在最后(如果存在) + return userNodes + defaultNodes + } + + /** + * 判断是否是默认节点 + */ + private fun isDefaultNode(node: RpcNodeConfig): Boolean { + return node.httpUrl == DEFAULT_RPC_URL || + node.httpUrl == DEFAULT_RPC_URL.removeSuffix("/") || + (node.providerType == RpcProviderType.PUBLIC.name && + (node.httpUrl.contains("polygon.publicnode.com") || + node.httpUrl.contains("publicnode.com"))) + } + + /** + * 获取第一个可用的节点 + * 按优先级顺序遍历所有启用的节点,找到第一个真正可用的节点 + * 如果所有节点都不可用,返回默认节点 + * @return 可用节点的配置,如果没有可用节点则返回失败 + */ + fun getAvailableNode(): Result { + return try { + val nodes = rpcNodeConfigRepository.findAllByEnabledTrueOrderByPriorityAsc() + .filterNot { isDefaultNode(it) } // 排除默认节点 + + if (nodes.isEmpty()) { + logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL") + return Result.failure(IllegalStateException("没有配置任何 RPC 节点")) + } + + // 优先使用最近检查状态为 HEALTHY 的节点 + val healthyNodes = nodes.filter { + it.lastCheckStatus == NodeHealthStatus.HEALTHY.name + } + + // 先尝试使用健康的节点(按优先级排序) + for (node in healthyNodes) { + try { + // 快速验证节点是否仍然可用(使用较短的超时时间) + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull() + if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) { + logger.debug("使用健康的 RPC 节点: ${node.name} (${node.httpUrl})") + return Result.success(node) + } + } catch (e: Exception) { + logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}") + // 继续尝试下一个节点 + } + } + + // 如果没有健康的节点,尝试验证所有节点(按优先级) + for (node in nodes) { + try { + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull() + if (checkResult != null && checkResult.status == NodeHealthStatus.HEALTHY) { + logger.info("找到可用的 RPC 节点: ${node.name} (${node.httpUrl})") + return Result.success(node) + } + } catch (e: Exception) { + logger.debug("节点 ${node.name} 验证失败,尝试下一个节点: ${e.message}") + // 继续尝试下一个节点 + } + } + + // 所有节点都不可用,返回失败 + logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL") + Result.failure(IllegalStateException("所有 RPC 节点都不可用")) + } catch (e: Exception) { + logger.error("获取可用节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取节点的 HTTP URL (如果没有配置,使用默认节点) + */ + fun getHttpUrl(): String { + val nodeResult = getAvailableNode() + return if (nodeResult.isSuccess) { + nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL + } else { + logger.warn("没有可用的用户配置节点,使用默认节点") + DEFAULT_RPC_URL + } + } + + /** + * 获取节点的 WebSocket URL (如果没有配置,使用默认节点) + */ + fun getWsUrl(): String { + val nodeResult = getAvailableNode() + return if (nodeResult.isSuccess) { + nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL + } else { + logger.warn("没有可用的用户配置节点,使用默认 WS 节点") + DEFAULT_WS_URL + } + } + + /** + * 添加节点 + */ + @Transactional + fun addNode(request: AddRpcNodeRequest): Result { + return try { + // 1. 验证请求 + val providerType = try { + RpcProviderType.valueOf(request.providerType.uppercase()) + } catch (e: IllegalArgumentException) { + return Result.failure(IllegalArgumentException("不支持的服务商类型: ${request.providerType}")) + } + + // 2. 构建 HTTP 和 WS URL + val (httpUrl, wsUrl) = if (providerType == RpcProviderType.CUSTOM) { + // 自定义节点,使用用户提供的 URL + if (request.httpUrl.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("自定义节点必须提供 HTTP URL")) + } + Pair(request.httpUrl, request.wsUrl) + } else { + // 主流服务商,使用模板生成 URL + if (request.apiKey.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("${request.providerType} 节点必须提供 API Key")) + } + val httpTemplate = PROVIDER_HTTP_TEMPLATES[providerType] + ?: return Result.failure(IllegalArgumentException("未找到 ${request.providerType} 的 HTTP URL 模板")) + val wsTemplate = PROVIDER_WS_TEMPLATES[providerType] + Pair( + httpTemplate.replace("{apiKey}", request.apiKey), + wsTemplate?.replace("{apiKey}", request.apiKey) + ) + } + + // 3. 校验节点可用性 + val validationResult = validateNode(httpUrl, wsUrl) + if (validationResult.isFailure) { + return Result.failure(validationResult.exceptionOrNull() ?: Exception("节点验证失败")) + } + + val checkResult = validationResult.getOrNull()!! + + // 检查节点是否健康,如果不健康则不允许添加 + if (checkResult.status != NodeHealthStatus.HEALTHY) { + return Result.failure(IllegalArgumentException("节点不可用: ${checkResult.message}")) + } + + // 4. 加密 API Key (如果有) + val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) } + + // 5. 获取当前最大优先级 + val maxPriority = rpcNodeConfigRepository.findAllByOrderByPriorityAsc() + .maxOfOrNull { it.priority } ?: 0 + + // 6. 创建节点配置 + val node = RpcNodeConfig( + providerType = providerType.name, + name = request.name, + httpUrl = httpUrl, + wsUrl = wsUrl, + apiKey = encryptedApiKey, + enabled = true, + priority = maxPriority + 1, // 新节点放到最后 + lastCheckTime = checkResult.checkTime, + lastCheckStatus = checkResult.status.name, + responseTimeMs = checkResult.responseTimeMs + ) + + val savedNode = rpcNodeConfigRepository.save(node) + logger.info("成功添加 RPC 节点: ${savedNode.name} (${savedNode.httpUrl})") + Result.success(savedNode) + } catch (e: Exception) { + logger.error("添加节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 更新节点 + * 默认节点不允许更新(作为兜底,不应该返回给前端) + */ + @Transactional + fun updateNode(request: UpdateRpcNodeRequest): Result { + return try { + val node = rpcNodeConfigRepository.findById(request.id).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: ${request.id}")) + + // 如果是默认节点,不允许更新 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许更新")) + } + + // 更新字段 + val updatedNode = node.copy( + name = request.name ?: node.name, + enabled = request.enabled ?: node.enabled, + priority = request.priority ?: node.priority, + updatedAt = System.currentTimeMillis() + ) + + val savedNode = rpcNodeConfigRepository.save(updatedNode) + logger.info("成功更新 RPC 节点: ${savedNode.name}") + Result.success(savedNode) + } catch (e: Exception) { + logger.error("更新节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 删除节点 + * 默认节点不允许删除(作为兜底,不应该返回给前端) + */ + @Transactional + fun deleteNode(id: Long): Result { + return try { + val node = rpcNodeConfigRepository.findById(id).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: $id")) + + // 如果是默认节点,不允许删除 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许删除")) + } + + rpcNodeConfigRepository.delete(node) + logger.info("成功删除 RPC 节点: ${node.name}") + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除节点失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 更新节点优先级 + * 默认节点不允许更新优先级(作为兜底,始终排在最后) + */ + @Transactional + fun updatePriority(id: Long, priority: Int): Result { + return try { + val node = rpcNodeConfigRepository.findById(id).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: $id")) + + // 如果是默认节点,不允许更新优先级 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许更新优先级")) + } + + val updatedNode = node.copy( + priority = priority, + updatedAt = System.currentTimeMillis() + ) + + rpcNodeConfigRepository.save(updatedNode) + logger.info("成功更新节点优先级: ${node.name} -> $priority") + Result.success(Unit) + } catch (e: Exception) { + logger.error("更新节点优先级失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 检查单个节点健康状态 + * 默认节点不应该被检查(作为兜底,不应该返回给前端) + */ + @Transactional + fun checkNodeHealth(nodeId: Long): Result { + return try { + val node = rpcNodeConfigRepository.findById(nodeId).orElse(null) + ?: return Result.failure(IllegalArgumentException("节点不存在: $nodeId")) + + // 如果是默认节点,不允许检查 + if (isDefaultNode(node)) { + return Result.failure(IllegalArgumentException("默认节点不允许检查")) + } + + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrThrow() + + // 更新节点健康状态 + val updatedNode = node.copy( + lastCheckTime = checkResult.checkTime, + lastCheckStatus = checkResult.status.name, + responseTimeMs = checkResult.responseTimeMs, + updatedAt = System.currentTimeMillis() + ) + + rpcNodeConfigRepository.save(updatedNode) + logger.info("检查节点健康状态: ${node.name} -> ${checkResult.status}") + Result.success(checkResult) + } catch (e: Exception) { + logger.error("检查节点健康状态失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 批量检查所有节点健康状态(不包含默认节点) + * 默认节点作为兜底,不应该返回给前端 + */ + @Transactional + fun checkAllNodesHealth(): Result> { + return try { + val allNodes = rpcNodeConfigRepository.findAll() + // 过滤掉默认节点,只检查用户配置的节点 + val nodes = allNodes.filterNot { isDefaultNode(it) } + val results = mutableMapOf() + + for (node in nodes) { + try { + val checkResult = validateNode(node.httpUrl, node.wsUrl).getOrNull() + if (checkResult != null) { + results[node.id!!] = checkResult + + // 更新节点状态 + val updatedNode = node.copy( + lastCheckTime = checkResult.checkTime, + lastCheckStatus = checkResult.status.name, + responseTimeMs = checkResult.responseTimeMs, + updatedAt = System.currentTimeMillis() + ) + rpcNodeConfigRepository.save(updatedNode) + } + } catch (e: Exception) { + logger.error("检查节点 ${node.name} 失败: ${e.message}", e) + } + } + + Result.success(results) + } catch (e: Exception) { + logger.error("批量检查节点健康状态失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 校验节点可用性 + * 调用 eth_blockNumber 验证节点是否可用 + */ + private fun validateNode(httpUrl: String, wsUrl: String?): Result { + return try { + logger.debug("开始验证节点: $httpUrl") + + // 创建临时 RPC API + val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl) + + // 调用 eth_blockNumber + val startTime = System.currentTimeMillis() + val rpcRequest = JsonRpcRequest( + method = "eth_blockNumber", + params = emptyList() + ) + + val response = kotlinx.coroutines.runBlocking { rpcApi.call(rpcRequest) } + val responseTime = (System.currentTimeMillis() - startTime).toInt() + + if (!response.isSuccessful || response.body() == null) { + logger.warn("节点验证失败: HTTP ${response.code()}") + return Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "HTTP 请求失败: ${response.code()}", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime + )) + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + logger.warn("节点验证失败: RPC 错误 ${rpcResponse.error.message}") + return Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "RPC 错误: ${rpcResponse.error.message}", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime + )) + } + + val blockNumber = rpcResponse.result + if (blockNumber.isNullOrBlank()) { + return Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "区块号为空", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime + )) + } + + logger.info("节点验证成功: $httpUrl, 区块号: $blockNumber, 响应时间: ${responseTime}ms") + Result.success(NodeCheckResult( + status = NodeHealthStatus.HEALTHY, + message = "节点可用, 当前区块: $blockNumber", + checkTime = System.currentTimeMillis(), + responseTimeMs = responseTime, + blockNumber = blockNumber + )) + } catch (e: Exception) { + logger.error("验证节点失败: ${e.message}", e) + Result.success(NodeCheckResult( + status = NodeHealthStatus.UNHEALTHY, + message = "验证失败: ${e.message}", + checkTime = System.currentTimeMillis(), + responseTimeMs = null + )) + } + } +} + +/** + * 添加节点请求 + */ +data class AddRpcNodeRequest( + val providerType: String, // ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC + val name: String, + val apiKey: String? = null, // 主流服务商需要 + val httpUrl: String? = null, // CUSTOM 需要 + val wsUrl: String? = null +) + +/** + * 更新节点请求 + */ +data class UpdateRpcNodeRequest( + val id: Long, + val name: String? = null, + val enabled: Boolean? = null, + val priority: Int? = null +) + +/** + * 节点检查结果 + */ +data class NodeCheckResult( + val status: NodeHealthStatus, + val message: String, + val checkTime: Long, + val responseTimeMs: Int?, + val blockNumber: String? = null +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 7a8542f..14f574b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -8,9 +8,16 @@ import com.wrbug.polymarketbot.api.GitHubApi import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PolymarketGammaApi +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer +import java.util.concurrent.TimeUnit import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @@ -95,11 +102,32 @@ class RetrofitFactory( /** * 创建 Ethereum RPC API 客户端 + * 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL + * 如果 RPC 不可用,将抛出异常 * @param rpcUrl RPC 节点 URL * @return EthereumRpcApi 客户端 + * @throws IllegalArgumentException 如果 RPC URL 无效或不可用 */ fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi { - val okHttpClient = createClient().build() + // 使用固定的 baseUrl(Retrofit 要求 baseUrl 必须以 / 结尾) + val fixedBaseUrl = "https://polyrpc.polyhermes/" + + // 确保实际的 RPC URL 以 / 结尾 + val actualRpcUrl = if (rpcUrl.endsWith("/")) { + rpcUrl + } else { + "$rpcUrl/" + } + + // 验证 RPC 是否可用 + validateRpcAvailability(actualRpcUrl) + + // 创建 URL 替换拦截器 + val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl) + + val okHttpClient = createClient() + .addInterceptor(urlReplaceInterceptor) + .build() // 创建 lenient 模式的 Gson val gson = GsonBuilder() @@ -107,13 +135,86 @@ class RetrofitFactory( .create() return Retrofit.Builder() - .baseUrl(rpcUrl) + .baseUrl(fixedBaseUrl) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .build() .create(EthereumRpcApi::class.java) } + /** + * 验证 RPC 节点是否可用 + * 通过发送一个简单的 eth_blockNumber 请求来验证 + * @param rpcUrl RPC 节点 URL + * @throws IllegalArgumentException 如果 RPC 不可用 + */ + private fun validateRpcAvailability(rpcUrl: String) { + val logger = LoggerFactory.getLogger(RetrofitFactory::class.java) + + try { + // 解析 URL + val httpUrl = rpcUrl.toHttpUrlOrNull() + ?: throw IllegalArgumentException("无效的 RPC URL: $rpcUrl") + + // 创建 JSON-RPC 请求体 + val jsonRpcRequest = """ + { + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + } + """.trimIndent() + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = jsonRpcRequest.toRequestBody(mediaType) + + // 创建请求 + val request = Request.Builder() + .url(httpUrl) + .post(requestBody) + .header("Content-Type", "application/json") + .build() + + // 创建临时客户端用于验证(使用较短的超时时间) + val testClient = createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + // 发送请求 + val response = testClient.newCall(request).execute() + + if (!response.isSuccessful) { + throw IllegalArgumentException("RPC 节点不可用: HTTP ${response.code} ${response.message}") + } + + val responseBody = response.body?.string() + if (responseBody.isNullOrBlank()) { + throw IllegalArgumentException("RPC 节点响应为空") + } + + // 检查响应是否包含错误 + if (responseBody.contains("\"error\"")) { + throw IllegalArgumentException("RPC 节点返回错误: $responseBody") + } + + // 检查响应是否包含 result + if (!responseBody.contains("\"result\"")) { + throw IllegalArgumentException("RPC 节点响应格式错误: $responseBody") + } + + logger.debug("RPC 节点验证成功: $rpcUrl") + } catch (e: IllegalArgumentException) { + logger.error("RPC 节点验证失败: $rpcUrl - ${e.message}") + throw e + } catch (e: Exception) { + logger.error("RPC 节点验证失败: $rpcUrl - ${e.message}", e) + throw IllegalArgumentException("RPC 节点不可用: ${e.message}", e) + } + } + /** * 创建 Polymarket Gamma API 客户端 * Gamma API 是公开 API,不需要认证 @@ -239,6 +340,39 @@ class RetrofitFactory( } } +/** + * RPC URL 替换拦截器 + * 用于将固定的 baseUrl 替换为实际的 RPC URL + */ +class RpcUrlReplaceInterceptor( + private val fixedBaseUrl: String, + private val actualRpcUrl: String +) : Interceptor { + private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java) + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val originalUrl = originalRequest.url + + // 将固定 baseUrl 替换为实际的 RPC URL + val originalUrlString = originalUrl.toString() + val newUrlString = originalUrlString.replace(fixedBaseUrl, actualRpcUrl) + + // 使用 HttpUrl 解析新 URL,确保格式正确 + val newUrl = newUrlString.toHttpUrlOrNull() + ?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString") + + logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString") + + val newRequest = originalRequest.newBuilder() + .url(newUrl) + .build() + + return chain.proceed(newRequest) + } +} + /** * 响应日志拦截器 * 用于记录 API 响应的原始内容,帮助调试 JSON 解析错误 diff --git a/backend/src/main/resources/db/migration/V12__create_rpc_node_config.sql b/backend/src/main/resources/db/migration/V12__create_rpc_node_config.sql new file mode 100644 index 0000000..4562b95 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__create_rpc_node_config.sql @@ -0,0 +1,41 @@ +-- 创建 RPC 节点配置表 +CREATE TABLE rpc_node_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', + provider_type VARCHAR(50) NOT NULL COMMENT '服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC', + name VARCHAR(100) NOT NULL COMMENT '节点名称', + http_url VARCHAR(500) NOT NULL COMMENT 'HTTP RPC URL', + ws_url VARCHAR(500) COMMENT 'WebSocket URL (可选)', + api_key VARCHAR(200) COMMENT 'API Key (加密存储)', + enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用', + priority INT DEFAULT 0 COMMENT '优先级(数字越小优先级越高)', + last_check_time BIGINT COMMENT '最后检查时间(毫秒时间戳)', + last_check_status VARCHAR(20) COMMENT '最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN', + response_time_ms INT COMMENT '最后一次响应时间(毫秒)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_enabled_priority (enabled, priority), + INDEX idx_last_check_status (last_check_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Polygon RPC 节点配置表'; + +-- 插入默认公共节点 (PublicNode) +INSERT INTO rpc_node_config ( + provider_type, + name, + http_url, + ws_url, + enabled, + priority, + created_at, + updated_at, + last_check_status +) VALUES ( + 'PUBLIC', + 'PublicNode (Default)', + 'https://polygon.publicnode.com', + 'wss://polygon.publicnode.com', + TRUE, + 999, + UNIX_TIMESTAMP() * 1000, + UNIX_TIMESTAMP() * 1000, + 'UNKNOWN' +); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f6f9eb..0cb0119 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' import ApiHealthStatus from './pages/ApiHealthStatus' +import RpcNodeSettings from './pages/RpcNodeSettings' import Announcements from './pages/Announcements' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' @@ -263,7 +264,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> {/* 默认重定向到登录页 */} } /> diff --git a/frontend/src/components/AddRpcNodeModal.tsx b/frontend/src/components/AddRpcNodeModal.tsx new file mode 100644 index 0000000..db74685 --- /dev/null +++ b/frontend/src/components/AddRpcNodeModal.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react' +import { Modal, Form, Input, Select, message, Space } from 'antd' +import { LinkOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { RpcNodeAddRequest } from '../types' +import { useTranslation } from 'react-i18next' + +interface AddRpcNodeModalProps { + visible: boolean + onCancel: () => void + onSuccess: () => void +} + +const { Option } = Select + +const AddRpcNodeModal: React.FC = ({ visible, onCancel, onSuccess }) => { + const { t } = useTranslation() + const [form] = Form.useForm() + const [selectedProvider, setSelectedProvider] = useState('CUSTOM') + const [validating, setValidating] = useState(false) + + const providerOptions = [ + { value: 'ALCHEMY', label: t('rpcNodeSettings.providerAlchemy'), url: 'https://dashboard.alchemy.com/' }, + { value: 'INFURA', label: t('rpcNodeSettings.providerInfura'), url: 'https://infura.io/' }, + { value: 'QUICKNODE', label: t('rpcNodeSettings.providerQuickNode'), url: 'https://www.quicknode.com/' }, + { value: 'CHAINSTACK', label: t('rpcNodeSettings.providerChainstack'), url: 'https://chainstack.com/' }, + { value: 'GETBLOCK', label: t('rpcNodeSettings.providerGetBlock'), url: 'https://getblock.io/' }, + { value: 'CUSTOM', label: t('rpcNodeSettings.customNode'), url: '' } + ] + + const handleSubmit = async () => { + try { + const values = await form.validateFields() + setValidating(true) + + const request: RpcNodeAddRequest = { + providerType: values.providerType, + name: values.name, + apiKey: values.apiKey, + httpUrl: values.httpUrl, + wsUrl: values.wsUrl + } + + // 先验证节点 + const validateResponse = await apiService.rpcNodes.validate(request) + + if (validateResponse.data.code === 0 && validateResponse.data.data) { + const result = validateResponse.data.data + + if (!result.valid) { + message.error(`${t('rpcNodeSettings.validateFailed')} ${result.message}`) + setValidating(false) + return + } + + // 验证通过,添加节点 + const addResponse = await apiService.rpcNodes.add(request) + + if (addResponse.data.code === 0) { + message.success(t('rpcNodeSettings.addSuccess')) + form.resetFields() + setSelectedProvider('CUSTOM') + onSuccess() + } else { + message.error(addResponse.data.msg || t('rpcNodeSettings.addFailed')) + } + } else { + message.error(validateResponse.data.msg || t('rpcNodeSettings.validateError')) + } + } catch (error: any) { + if (!error.errorFields) { + message.error(error.message || t('rpcNodeSettings.operationFailed')) + } + } finally { + setValidating(false) + } + } + + const handleCancel = () => { + form.resetFields() + setSelectedProvider('CUSTOM') + onCancel() + } + + const currentProvider = providerOptions.find(p => p.value === selectedProvider) + + return ( + +
+ + + + + + + + + {selectedProvider !== 'CUSTOM' && ( + + {t('rpcNodeSettings.apiKeyLabel')} + {currentProvider?.url && ( + + {t('rpcNodeSettings.getApiKey')} + + )} + + } + name="apiKey" + rules={[{ required: true, message: t('rpcNodeSettings.apiKeyRequired') }]} + > + + + )} + + {selectedProvider === 'CUSTOM' && ( + <> + + + + + + + + + )} +
+
+ ) +} + +export default AddRpcNodeModal diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d0df2ac..fff50fe 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -19,7 +19,7 @@ import { TwitterOutlined, CheckCircleOutlined, SendOutlined, - NotificationOutlined + ApiOutlined, NotificationOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -131,6 +131,11 @@ const Layout: React.FC = ({ children }) => { icon: , label: t('menu.systemOverview') || '通用设置' }, + { + key: '/system-settings/rpc-nodes', + icon: , + label: t('menu.rpcNodes') || 'RPC节点管理' + }, { key: '/system-settings/api-health', icon: , diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index d2addbe..e951034 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -237,6 +237,7 @@ "systemSettings": "System", "systemOverview": "Overview", "language": "Language", + "rpcNodes": "RPC Nodes", "apiHealth": "API Health", "builderApiKey": "Builder API Key", "proxy": "Proxy", @@ -1065,5 +1066,62 @@ "averagePnl": "Average PnL", "maxPnl": "Max PnL", "minPnl": "Min PnL" + }, + "rpcNodeSettings": { + "title": "Polygon RPC Node Configuration", + "fetchFailed": "Failed to fetch node list", + "checkHealthSuccess": "Health check completed", + "checkHealthFailed": "Health check failed", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Failed to delete", + "adjustPrioritySuccess": "Priority adjusted successfully", + "adjustPriorityFailed": "Failed to adjust priority", + "priority": "Priority", + "providerType": "Provider", + "name": "Name", + "status": "Status", + "statusHealthy": "Available", + "statusUnhealthy": "Unavailable", + "statusUnknown": "Unknown", + "responseTime": "Response Time", + "actions": "Actions", + "check": "Check", + "checkSuccess": "Check completed", + "checkFailed": "Check failed", + "deleteConfirm": "Are you sure you want to delete this node?", + "deleteConfirmOk": "Confirm", + "deleteConfirmCancel": "Cancel", + "delete": "Delete", + "batchCheck": "Batch Check", + "addNode": "Add Node", + "addNodeTitle": "Add RPC Node", + "validateAndAdd": "Validate and Add", + "providerTypeLabel": "Provider Type", + "providerTypeRequired": "Please select provider type", + "nodeNameLabel": "Node Name", + "nodeNameRequired": "Please enter node name", + "nodeNamePlaceholder": "e.g.: My Alchemy Node", + "apiKeyLabel": "API Key", + "getApiKey": "Get API Key", + "apiKeyRequired": "Please enter API Key", + "apiKeyPlaceholder": "Enter your API Key", + "httpUrlLabel": "HTTP RPC URL", + "httpUrlRequired": "Please enter HTTP RPC URL", + "httpUrlInvalid": "Please enter a valid URL", + "httpUrlPlaceholder": "https://polygon-rpc.com", + "wsUrlLabel": "WebSocket URL (Optional)", + "wsUrlInvalid": "Please enter a valid URL", + "wsUrlPlaceholder": "wss://polygon-rpc.com", + "validateFailed": "Node validation failed:", + "addSuccess": "Added successfully", + "addFailed": "Failed to add", + "validateError": "Validation failed", + "operationFailed": "Operation failed", + "customNode": "Custom Node", + "providerAlchemy": "Alchemy", + "providerInfura": "Infura", + "providerQuickNode": "QuickNode", + "providerChainstack": "Chainstack", + "providerGetBlock": "GetBlock" } } diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 930b976..c9e6cce 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -237,6 +237,7 @@ "systemSettings": "系统管理", "systemOverview": "概览", "language": "语言", + "rpcNodes": "RPC节点管理", "apiHealth": "API健康", "builderApiKey": "Builder API Key", "proxy": "代理", @@ -1065,5 +1066,62 @@ "averagePnl": "平均盈亏", "maxPnl": "最大盈亏", "minPnl": "最小盈亏" + }, + "rpcNodeSettings": { + "title": "Polygon RPC 节点配置", + "fetchFailed": "获取节点列表失败", + "checkHealthSuccess": "健康检查完成", + "checkHealthFailed": "健康检查失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "adjustPrioritySuccess": "调整成功", + "adjustPriorityFailed": "调整失败", + "priority": "优先级", + "providerType": "服务商", + "name": "名称", + "status": "状态", + "statusHealthy": "可用", + "statusUnhealthy": "不可用", + "statusUnknown": "未知", + "responseTime": "响应时间", + "actions": "操作", + "check": "检查", + "checkSuccess": "检查完成", + "checkFailed": "检查失败", + "deleteConfirm": "确定删除此节点吗?", + "deleteConfirmOk": "确定", + "deleteConfirmCancel": "取消", + "delete": "删除", + "batchCheck": "批量检查", + "addNode": "添加节点", + "addNodeTitle": "添加 RPC 节点", + "validateAndAdd": "验证并添加", + "providerTypeLabel": "服务商类型", + "providerTypeRequired": "请选择服务商类型", + "nodeNameLabel": "节点名称", + "nodeNameRequired": "请输入节点名称", + "nodeNamePlaceholder": "例如: My Alchemy Node", + "apiKeyLabel": "API Key", + "getApiKey": "获取 API Key", + "apiKeyRequired": "请输入 API Key", + "apiKeyPlaceholder": "输入您的 API Key", + "httpUrlLabel": "HTTP RPC URL", + "httpUrlRequired": "请输入 HTTP RPC URL", + "httpUrlInvalid": "请输入有效的 URL", + "httpUrlPlaceholder": "https://polygon-rpc.com", + "wsUrlLabel": "WebSocket URL (可选)", + "wsUrlInvalid": "请输入有效的 URL", + "wsUrlPlaceholder": "wss://polygon-rpc.com", + "validateFailed": "节点验证失败:", + "addSuccess": "添加成功", + "addFailed": "添加失败", + "validateError": "验证失败", + "operationFailed": "操作失败", + "customNode": "自定义节点", + "providerAlchemy": "Alchemy", + "providerInfura": "Infura", + "providerQuickNode": "QuickNode", + "providerChainstack": "Chainstack", + "providerGetBlock": "GetBlock" } } diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index a9ebba6..173c769 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -237,6 +237,7 @@ "systemSettings": "系統管理", "systemOverview": "概覽", "language": "語言", + "rpcNodes": "RPC節點管理", "apiHealth": "API健康", "builderApiKey": "Builder API Key", "proxy": "代理", @@ -1065,5 +1066,62 @@ "averagePnl": "平均盈虧", "maxPnl": "最大盈虧", "minPnl": "最小盈虧" + }, + "rpcNodeSettings": { + "title": "Polygon RPC 節點配置", + "fetchFailed": "獲取節點列表失敗", + "checkHealthSuccess": "健康檢查完成", + "checkHealthFailed": "健康檢查失敗", + "deleteSuccess": "刪除成功", + "deleteFailed": "刪除失敗", + "adjustPrioritySuccess": "調整成功", + "adjustPriorityFailed": "調整失敗", + "priority": "優先級", + "providerType": "服務商", + "name": "名稱", + "status": "狀態", + "statusHealthy": "可用", + "statusUnhealthy": "不可用", + "statusUnknown": "未知", + "responseTime": "響應時間", + "actions": "操作", + "check": "檢查", + "checkSuccess": "檢查完成", + "checkFailed": "檢查失敗", + "deleteConfirm": "確定刪除此節點嗎?", + "deleteConfirmOk": "確定", + "deleteConfirmCancel": "取消", + "delete": "刪除", + "batchCheck": "批量檢查", + "addNode": "添加節點", + "addNodeTitle": "添加 RPC 節點", + "validateAndAdd": "驗證並添加", + "providerTypeLabel": "服務商類型", + "providerTypeRequired": "請選擇服務商類型", + "nodeNameLabel": "節點名稱", + "nodeNameRequired": "請輸入節點名稱", + "nodeNamePlaceholder": "例如: My Alchemy Node", + "apiKeyLabel": "API Key", + "getApiKey": "獲取 API Key", + "apiKeyRequired": "請輸入 API Key", + "apiKeyPlaceholder": "輸入您的 API Key", + "httpUrlLabel": "HTTP RPC URL", + "httpUrlRequired": "請輸入 HTTP RPC URL", + "httpUrlInvalid": "請輸入有效的 URL", + "httpUrlPlaceholder": "https://polygon-rpc.com", + "wsUrlLabel": "WebSocket URL (可選)", + "wsUrlInvalid": "請輸入有效的 URL", + "wsUrlPlaceholder": "wss://polygon-rpc.com", + "validateFailed": "節點驗證失敗:", + "addSuccess": "添加成功", + "addFailed": "添加失敗", + "validateError": "驗證失敗", + "operationFailed": "操作失敗", + "customNode": "自定義節點", + "providerAlchemy": "Alchemy", + "providerInfura": "Infura", + "providerQuickNode": "QuickNode", + "providerChainstack": "Chainstack", + "providerGetBlock": "GetBlock" } } diff --git a/frontend/src/pages/RpcNodeSettings.tsx b/frontend/src/pages/RpcNodeSettings.tsx new file mode 100644 index 0000000..f1783d1 --- /dev/null +++ b/frontend/src/pages/RpcNodeSettings.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from 'react' +import { Card, Table, Button, Space, Badge, message, Popconfirm, Tag } from 'antd' +import { UpOutlined, DownOutlined, DeleteOutlined, ReloadOutlined, PlusOutlined, ApiOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { RpcNodeConfig } from '../types' +import AddRpcNodeModal from '../components/AddRpcNodeModal' +import { useTranslation } from 'react-i18next' + +const RpcNodeSettings: React.FC = () => { + const { t } = useTranslation() + const [nodes, setNodes] = useState([]) + const [loading, setLoading] = useState(false) + const [checking, setChecking] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + + useEffect(() => { + fetchNodes() + }, []) + + const fetchNodes = async () => { + setLoading(true) + try { + const response = await apiService.rpcNodes.list() + if (response.data.code === 0 && response.data.data) { + setNodes(response.data.data) + } else { + message.error(response.data.msg || t('rpcNodeSettings.fetchFailed')) + } + } catch (error: any) { + message.error(error.message || t('rpcNodeSettings.fetchFailed')) + } finally { + setLoading(false) + } + } + + const handleCheckAllHealth = async () => { + setChecking(true) + try { + const response = await apiService.rpcNodes.checkHealth({}) + if (response.data.code === 0) { + message.success(t('rpcNodeSettings.checkHealthSuccess')) + fetchNodes() + } else { + message.error(response.data.msg || t('rpcNodeSettings.checkHealthFailed')) + } + } catch (error: any) { + message.error(error.message || t('rpcNodeSettings.checkHealthFailed')) + } finally { + setChecking(false) + } + } + + const handleDelete = async (id: number) => { + try { + const response = await apiService.rpcNodes.delete({ id }) + if (response.data.code === 0) { + message.success(t('rpcNodeSettings.deleteSuccess')) + fetchNodes() + } else { + message.error(response.data.msg || t('rpcNodeSettings.deleteFailed')) + } + } catch (error: any) { + message.error(error.message || t('rpcNodeSettings.deleteFailed')) + } + } + + const handleMovePriority = async (id: number, direction: 'up' | 'down') => { + const index = nodes.findIndex(n => n.id === id) + if (index === -1) return + + if (direction === 'up' && index === 0) return + if (direction === 'down' && index === nodes.length - 1) return + + const targetIndex = direction === 'up' ? index - 1 : index + 1 + const newPriority = nodes[targetIndex].priority + + try { + const response = await apiService.rpcNodes.updatePriority({ id, priority: newPriority }) + if (response.data.code === 0) { + // 同时更新另一个节点的优先级 + await apiService.rpcNodes.updatePriority({ + id: nodes[targetIndex].id, + priority: nodes[index].priority + }) + message.success(t('rpcNodeSettings.adjustPrioritySuccess')) + fetchNodes() + } else { + message.error(response.data.msg || t('rpcNodeSettings.adjustPriorityFailed')) + } + } catch (error: any) { + message.error(error.message || t('rpcNodeSettings.adjustPriorityFailed')) + } + } + + const columns = [ + { + title: t('rpcNodeSettings.priority'), + dataIndex: 'priority', + width: 120, + render: (_: any, record: RpcNodeConfig, index: number) => ( + + + handleDelete(record.id)} + okText={t('rpcNodeSettings.deleteConfirmOk')} + cancelText={t('rpcNodeSettings.deleteConfirmCancel')} + > + + + + ) + } + ] + + return ( + + + {t('rpcNodeSettings.title')} + + } + extra={ + + + + + } + > + + + setModalVisible(false)} + onSuccess={() => { + setModalVisible(false) + fetchNodes() + }} + /> + + ) +} + +export default RpcNodeSettings diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index 0447ecf..5095ae4 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -887,6 +887,7 @@ const SystemSettings: React.FC = () => { showIcon /> )} + ) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dcfb666..681a65c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -618,6 +618,33 @@ export const apiService = { /** * 公告 API */ + /** + * RPC 节点配置 API + */ + rpcNodes: { + list: () => + apiClient.post>('/system/rpc-nodes/list', {}), + + add: (data: import('../types').RpcNodeAddRequest) => + apiClient.post>('/system/rpc-nodes/add', data), + + update: (data: import('../types').RpcNodeUpdateRequest) => + apiClient.post>('/system/rpc-nodes/update', data), + + delete: (data: { id: number }) => + apiClient.post>('/system/rpc-nodes/delete', data), + + updatePriority: (data: { id: number; priority: number }) => + apiClient.post>('/system/rpc-nodes/update-priority', data), + + checkHealth: (data: { id?: number }) => + apiClient.post>('/system/rpc-nodes/check-health', data), + + validate: (data: import('../types').RpcNodeAddRequest) => + apiClient.post>('/system/rpc-nodes/validate', data) + }, + + announcements: { /** * 获取公告列表(最近10条) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4b0cd4e..8439858 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -806,3 +806,54 @@ export interface NotificationConfigUpdateRequest { } } + +/** + * RPC 节点配置类型 + */ +export interface RpcNodeConfig { + id: number + providerType: 'ALCHEMY' | 'INFURA' | 'QUICKNODE' | 'CHAINSTACK' | 'GETBLOCK' | 'CUSTOM' | 'PUBLIC' + name: string + httpUrl: string + wsUrl?: string + apiKeyMasked?: string // 脱敏后的 API Key + enabled: boolean + priority: number + lastCheckTime?: number + lastCheckStatus?: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN' + responseTimeMs?: number + createdAt: number + updatedAt: number +} + +/** + * 添加 RPC 节点请求 + */ +export interface RpcNodeAddRequest { + providerType: string + name: string + apiKey?: string // 主流服务商需要 + httpUrl?: string // CUSTOM 需要 + wsUrl?: string +} + +/** + * 更新 RPC 节点请求 + */ +export interface RpcNodeUpdateRequest { + id: number + name?: string + enabled?: boolean + priority?: number +} + +/** + * 节点健康检查结果 + */ +export interface NodeCheckResult { + status: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN' + message: string + checkTime: number + responseTimeMs?: number + blockNumber?: string +}