feat: 实现RPC节点管理功能
- 后端功能: - 添加RPC节点配置管理(增删改查、优先级调整) - 实现节点健康检查和自动故障转移 - 修复Retrofit baseUrl处理问题(使用拦截器动态替换URL) - 添加RPC节点可用性验证(创建前验证) - 默认节点作为兜底,不返回给前端,始终排在最后 - API健康检查使用动态获取的可用节点 - 前端功能: - 添加RPC节点设置页面 - 支持添加、删除、检查节点 - 支持调整节点优先级 - 完整的多语言支持(中文简体/繁体、英文) - 添加菜单项多语言key - 数据库: - 添加RPC节点配置表迁移脚本
This commit is contained in:
@@ -109,4 +109,5 @@ __pycache__/
|
||||
# Submodules and external dependencies
|
||||
clob-client/
|
||||
builder-relayer-client/
|
||||
landing-page/
|
||||
|
||||
|
||||
+366
@@ -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>
|
||||
}
|
||||
+13
-38
@@ -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(
|
||||
|
||||
+21
-11
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-17
@@ -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 Relayer(Gasless)"))
|
||||
}
|
||||
|
||||
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()
|
||||
// 使用固定的 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 解析错误
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -887,6 +887,7 @@ const SystemSettings: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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条)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user