feat: 实现RPC节点管理功能

- 后端功能:
  - 添加RPC节点配置管理(增删改查、优先级调整)
  - 实现节点健康检查和自动故障转移
  - 修复Retrofit baseUrl处理问题(使用拦截器动态替换URL)
  - 添加RPC节点可用性验证(创建前验证)
  - 默认节点作为兜底,不返回给前端,始终排在最后
  - API健康检查使用动态获取的可用节点

- 前端功能:
  - 添加RPC节点设置页面
  - 支持添加、删除、检查节点
  - 支持调整节点优先级
  - 完整的多语言支持(中文简体/繁体、英文)
  - 添加菜单项多语言key

- 数据库:
  - 添加RPC节点配置表迁移脚本
This commit is contained in:
WrBug
2025-12-25 23:58:50 +08:00
parent 7bc059ec34
commit 1580b60d24
20 changed files with 1856 additions and 70 deletions
+1
View File
@@ -109,4 +109,5 @@ __pycache__/
# Submodules and external dependencies
clob-client/
builder-relayer-client/
landing-page/
@@ -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<String, Any>?): ResponseEntity<ApiResponse<List<RpcNodeConfigDto>>> {
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<ApiResponse<RpcNodeConfigDto>> {
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<ApiResponse<RpcNodeConfigDto>> {
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<ApiResponse<Unit>> {
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<ApiResponse<Unit>> {
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<ApiResponse<Any>> {
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<ApiResponse<ValidateNodeResponse>> {
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
)
}
@@ -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
}
@@ -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<RpcNodeConfig, Long> {
/**
* 查询所有已启用的节点,按优先级排序(优先级数字越小越靠前)
*/
fun findAllByEnabledTrueOrderByPriorityAsc(): List<RpcNodeConfig>
/**
* 查询指定 ID 的已启用节点
*/
fun findByIdAndEnabledTrue(id: Long): RpcNodeConfig?
/**
* 查询所有节点,按优先级排序
*/
fun findAllByOrderByPriorityAsc(): List<RpcNodeConfig>
}
@@ -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<String> {
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<String> {
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<String> {
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<BigInteger> {
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<BigInteger> {
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<BigInteger> {
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<String> {
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(
@@ -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)
}
/**
@@ -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<String> {
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<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (polygonRpcUrl.isBlank()) {
logger.warn("未配置 Polygon RPC URL,无法执行交易")
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder RelayerGasless"))
}
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
val rpcApi = polygonRpcApi
// 从私钥推导实际签名地址(交易真正的 from 地址)
val cleanPrivateKey = privateKey.removePrefix("0x")
@@ -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<RpcNodeConfig> {
val allNodes = rpcNodeConfigRepository.findAllByOrderByPriorityAsc()
// 过滤掉默认节点,只返回用户配置的节点
return allNodes.filterNot { isDefaultNode(it) }
}
/**
* 获取所有节点配置(包含默认节点,用于内部使用)
* 默认节点始终排在最后
*/
fun getAllNodesWithDefault(): List<RpcNodeConfig> {
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<RpcNodeConfig> {
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<RpcNodeConfig> {
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<RpcNodeConfig> {
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<Unit> {
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<Unit> {
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<NodeCheckResult> {
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<Map<Long, NodeCheckResult>> {
return try {
val allNodes = rpcNodeConfigRepository.findAll()
// 过滤掉默认节点,只检查用户配置的节点
val nodes = allNodes.filterNot { isDefaultNode(it) }
val results = mutableMapOf<Long, NodeCheckResult>()
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<NodeCheckResult> {
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
)
@@ -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()
// 使用固定的 baseUrlRetrofit 要求 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 解析错误
@@ -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'
);
+2 -1
View File
@@ -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() {
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
{/* 默认重定向到登录页 */}
<Route path="*" element={<Navigate to="/login" replace />} />
+172
View File
@@ -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<AddRpcNodeModalProps> = ({ visible, onCancel, onSuccess }) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const [selectedProvider, setSelectedProvider] = useState<string>('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 (
<Modal
title={t('rpcNodeSettings.addNodeTitle')}
open={visible}
onOk={handleSubmit}
onCancel={handleCancel}
width={600}
confirmLoading={validating}
okText={t('rpcNodeSettings.validateAndAdd')}
cancelText={t('common.cancel')}
>
<Form
form={form}
layout="vertical"
initialValues={{ providerType: 'CUSTOM' }}
>
<Form.Item
label={t('rpcNodeSettings.providerTypeLabel')}
name="providerType"
rules={[{ required: true, message: t('rpcNodeSettings.providerTypeRequired') }]}
>
<Select onChange={setSelectedProvider}>
{providerOptions.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t('rpcNodeSettings.nodeNameLabel')}
name="name"
rules={[{ required: true, message: t('rpcNodeSettings.nodeNameRequired') }]}
>
<Input placeholder={t('rpcNodeSettings.nodeNamePlaceholder')} />
</Form.Item>
{selectedProvider !== 'CUSTOM' && (
<Form.Item
label={
<Space>
<span>{t('rpcNodeSettings.apiKeyLabel')}</span>
{currentProvider?.url && (
<a href={currentProvider.url} target="_blank" rel="noopener noreferrer">
<LinkOutlined /> {t('rpcNodeSettings.getApiKey')}
</a>
)}
</Space>
}
name="apiKey"
rules={[{ required: true, message: t('rpcNodeSettings.apiKeyRequired') }]}
>
<Input.Password
placeholder={t('rpcNodeSettings.apiKeyPlaceholder')}
autoComplete="off"
/>
</Form.Item>
)}
{selectedProvider === 'CUSTOM' && (
<>
<Form.Item
label={t('rpcNodeSettings.httpUrlLabel')}
name="httpUrl"
rules={[
{ required: true, message: t('rpcNodeSettings.httpUrlRequired') },
{ type: 'url', message: t('rpcNodeSettings.httpUrlInvalid') }
]}
>
<Input placeholder={t('rpcNodeSettings.httpUrlPlaceholder')} />
</Form.Item>
<Form.Item
label={t('rpcNodeSettings.wsUrlLabel')}
name="wsUrl"
rules={[{ type: 'url', message: t('rpcNodeSettings.wsUrlInvalid') }]}
>
<Input placeholder={t('rpcNodeSettings.wsUrlPlaceholder')} />
</Form.Item>
</>
)}
</Form>
</Modal>
)
}
export default AddRpcNodeModal
+6 -1
View File
@@ -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<LayoutProps> = ({ children }) => {
icon: <SettingOutlined />,
label: t('menu.systemOverview') || '通用设置'
},
{
key: '/system-settings/rpc-nodes',
icon: <ApiOutlined />,
label: t('menu.rpcNodes') || 'RPC节点管理'
},
{
key: '/system-settings/api-health',
icon: <CheckCircleOutlined />,
+58
View File
@@ -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"
}
}
+58
View File
@@ -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"
}
}
+58
View File
@@ -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"
}
}
+233
View File
@@ -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<RpcNodeConfig[]>([])
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) => (
<Space>
<Button
size="small"
icon={<UpOutlined />}
onClick={() => handleMovePriority(record.id, 'up')}
disabled={index === 0}
/>
<Button
size="small"
icon={<DownOutlined />}
onClick={() => handleMovePriority(record.id, 'down')}
disabled={index === nodes.length - 1}
/>
<span>{index + 1}</span>
</Space>
)
},
{
title: t('rpcNodeSettings.providerType'),
dataIndex: 'providerType',
width: 120,
render: (type: string) => <Tag color="blue">{type}</Tag>
},
{
title: t('rpcNodeSettings.name'),
dataIndex: 'name',
ellipsis: true
},
{
title: t('rpcNodeSettings.status'),
dataIndex: 'lastCheckStatus',
width: 100,
render: (status: string | undefined) => {
const statusMap = {
HEALTHY: { status: 'success' as const, text: t('rpcNodeSettings.statusHealthy') },
UNHEALTHY: { status: 'error' as const, text: t('rpcNodeSettings.statusUnhealthy') },
UNKNOWN: { status: 'default' as const, text: t('rpcNodeSettings.statusUnknown') }
}
const config = statusMap[status as keyof typeof statusMap] || statusMap.UNKNOWN
return <Badge status={config.status} text={config.text} />
}
},
{
title: t('rpcNodeSettings.responseTime'),
dataIndex: 'responseTimeMs',
width: 100,
render: (time: number | undefined) => time ? `${time}ms` : '-'
},
{
title: t('rpcNodeSettings.actions'),
key: 'action',
width: 150,
render: (_: any, record: RpcNodeConfig) => (
<Space size="small">
<Button
size="small"
onClick={async () => {
try {
const response = await apiService.rpcNodes.checkHealth({ id: record.id })
if (response.data.code === 0) {
message.success(t('rpcNodeSettings.checkSuccess'))
fetchNodes()
}
} catch (error: any) {
message.error(t('rpcNodeSettings.checkFailed'))
}
}}
>
{t('rpcNodeSettings.check')}
</Button>
<Popconfirm
title={t('rpcNodeSettings.deleteConfirm')}
onConfirm={() => handleDelete(record.id)}
okText={t('rpcNodeSettings.deleteConfirmOk')}
cancelText={t('rpcNodeSettings.deleteConfirmCancel')}
>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('rpcNodeSettings.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<Card
title={
<Space>
<ApiOutlined />
<span>{t('rpcNodeSettings.title')}</span>
</Space>
}
extra={
<Space>
<Button
icon={<ReloadOutlined />}
onClick={handleCheckAllHealth}
loading={checking}
>
{t('rpcNodeSettings.batchCheck')}
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setModalVisible(true)}
>
{t('rpcNodeSettings.addNode')}
</Button>
</Space>
}
>
<Table
dataSource={nodes}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
/>
<AddRpcNodeModal
visible={modalVisible}
onCancel={() => setModalVisible(false)}
onSuccess={() => {
setModalVisible(false)
fetchNodes()
}}
/>
</Card>
)
}
export default RpcNodeSettings
+1
View File
@@ -887,6 +887,7 @@ const SystemSettings: React.FC = () => {
showIcon
/>
)}
</Card>
</div>
)
+27
View File
@@ -618,6 +618,33 @@ export const apiService = {
/**
* 公告 API
*/
/**
* RPC 节点配置 API
*/
rpcNodes: {
list: () =>
apiClient.post<ApiResponse<import('../types').RpcNodeConfig[]>>('/system/rpc-nodes/list', {}),
add: (data: import('../types').RpcNodeAddRequest) =>
apiClient.post<ApiResponse<import('../types').RpcNodeConfig>>('/system/rpc-nodes/add', data),
update: (data: import('../types').RpcNodeUpdateRequest) =>
apiClient.post<ApiResponse<import('../types').RpcNodeConfig>>('/system/rpc-nodes/update', data),
delete: (data: { id: number }) =>
apiClient.post<ApiResponse<void>>('/system/rpc-nodes/delete', data),
updatePriority: (data: { id: number; priority: number }) =>
apiClient.post<ApiResponse<void>>('/system/rpc-nodes/update-priority', data),
checkHealth: (data: { id?: number }) =>
apiClient.post<ApiResponse<any>>('/system/rpc-nodes/check-health', data),
validate: (data: import('../types').RpcNodeAddRequest) =>
apiClient.post<ApiResponse<{ valid: boolean; message: string; responseTimeMs?: number }>>('/system/rpc-nodes/validate', data)
},
announcements: {
/**
* 获取公告列表(最近10条)
+51
View File
@@ -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
}