feat: 添加系统管理功能(代理配置和API健康检查)
- 新增代理配置管理功能,支持HTTP代理配置(host、port、用户名、密码) - 代理配置存储在数据库中,支持实时生效,无需重启服务 - 代理配置变更时自动重连WebSocket连接(订单推送和跟单服务) - 新增API健康状态检查功能,监控Polymarket各API和Polygon RPC的可用性 - 优化API健康状态UI,支持响应式设计(移动端和桌面端) - 将Ethereum RPC相关配置和代码统一改为Polygon RPC - 修复代理检查时的SSL证书验证问题 - 优化代理配置:关闭代理时保留配置信息,避免重新输入
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.service.ProxyConfigService
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* 代理配置初始化器
|
||||
* 在应用启动时初始化代理配置
|
||||
*/
|
||||
@Component
|
||||
class ProxyConfigInitializer(
|
||||
private val proxyConfigService: ProxyConfigService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ProxyConfigInitializer::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
try {
|
||||
proxyConfigService.initProxyConfig()
|
||||
} catch (e: Exception) {
|
||||
logger.error("初始化代理配置失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.ProxyConfigService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* 代理配置控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/proxy-config")
|
||||
class ProxyConfigController(
|
||||
private val proxyConfigService: ProxyConfigService,
|
||||
private val apiHealthCheckService: ApiHealthCheckService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ProxyConfigController::class.java)
|
||||
|
||||
/**
|
||||
* 获取当前代理配置
|
||||
*/
|
||||
@PostMapping("/get")
|
||||
fun getProxyConfig(): ResponseEntity<ApiResponse<ProxyConfigDto?>> {
|
||||
return try {
|
||||
val config = proxyConfigService.getProxyConfig()
|
||||
ResponseEntity.ok(ApiResponse.success(config))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取代理配置失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取代理配置失败:${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有代理配置(用于管理)
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getAllProxyConfigs(): ResponseEntity<ApiResponse<List<ProxyConfigDto>>> {
|
||||
return try {
|
||||
val configs = proxyConfigService.getAllProxyConfigs()
|
||||
ResponseEntity.ok(ApiResponse.success(configs))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取代理配置列表失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取代理配置列表失败:${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 HTTP 代理配置
|
||||
*/
|
||||
@PostMapping("/http/save")
|
||||
fun saveHttpProxyConfig(@RequestBody request: HttpProxyConfigRequest): ResponseEntity<ApiResponse<ProxyConfigDto>> {
|
||||
return try {
|
||||
val result = proxyConfigService.saveHttpProxyConfig(request)
|
||||
if (result.isSuccess) {
|
||||
ResponseEntity.ok(ApiResponse.success(result.getOrNull()))
|
||||
} else {
|
||||
val error = result.exceptionOrNull()
|
||||
logger.error("保存 HTTP 代理配置失败", error)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, error?.message ?: "保存失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("保存 HTTP 代理配置异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "保存 HTTP 代理配置失败:${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理是否可用
|
||||
*/
|
||||
@PostMapping("/check")
|
||||
fun checkProxy(): ResponseEntity<ApiResponse<ProxyCheckResponse>> {
|
||||
return try {
|
||||
val result = proxyConfigService.checkProxy()
|
||||
ResponseEntity.ok(ApiResponse.success(result))
|
||||
} catch (e: Exception) {
|
||||
logger.error("代理检查失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "代理检查失败:${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
fun deleteProxyConfig(@RequestBody request: Map<String, Long>): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val id = request["id"] ?: return ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.PARAM_ERROR, "参数错误:缺少 id")
|
||||
)
|
||||
|
||||
val result = proxyConfigService.deleteProxyConfig(id)
|
||||
if (result.isSuccess) {
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
} else {
|
||||
val error = result.exceptionOrNull()
|
||||
logger.error("删除代理配置失败:id=$id", error)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, error?.message ?: "删除失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除代理配置异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "删除代理配置失败:${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有 API 的健康状态
|
||||
*/
|
||||
@PostMapping("/api-health-check")
|
||||
fun checkApiHealth(): ResponseEntity<ApiResponse<ApiHealthCheckResponse>> {
|
||||
return try {
|
||||
val result = runBlocking { apiHealthCheckService.checkAllApis() }
|
||||
ResponseEntity.ok(ApiResponse.success(result))
|
||||
} catch (e: Exception) {
|
||||
logger.error("API 健康检查失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* API 健康检查响应
|
||||
*/
|
||||
data class ApiHealthCheckDto(
|
||||
val name: String, // API 名称
|
||||
val url: String, // API URL
|
||||
val status: String, // "success" 或 "error"
|
||||
val message: String, // 状态消息
|
||||
val responseTime: Long? = null // 响应时间(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 所有 API 健康检查响应
|
||||
*/
|
||||
data class ApiHealthCheckResponse(
|
||||
val apis: List<ApiHealthCheckDto>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 代理配置 DTO(用于返回给前端,不包含密码)
|
||||
*/
|
||||
data class ProxyConfigDto(
|
||||
val id: Long?,
|
||||
val type: String, // HTTP, CLASH, SS
|
||||
val enabled: Boolean,
|
||||
val host: String?,
|
||||
val port: Int?,
|
||||
val username: String?,
|
||||
val subscriptionUrl: String?,
|
||||
val lastSubscriptionUpdate: Long?,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建/更新 HTTP 代理配置请求
|
||||
*/
|
||||
data class HttpProxyConfigRequest(
|
||||
val enabled: Boolean,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val username: String? = null,
|
||||
val password: String? = null // 更新时如果为空则不更新密码
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建/更新订阅代理配置请求(第二阶段功能)
|
||||
*/
|
||||
data class SubscriptionProxyConfigRequest(
|
||||
val enabled: Boolean,
|
||||
val subscriptionUrl: String,
|
||||
val type: String // CLASH 或 SS
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理检查响应
|
||||
*/
|
||||
data class ProxyCheckResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val responseTime: Long? = null, // 响应时间(毫秒)
|
||||
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
) {
|
||||
companion object {
|
||||
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
|
||||
return ProxyCheckResponse(
|
||||
success = success,
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
latency = responseTime // latency 和 responseTime 相同
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 代理配置实体
|
||||
* 支持 HTTP 代理和订阅代理(Clash/SS)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "proxy_config")
|
||||
data class ProxyConfig(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "type", nullable = false, length = 20)
|
||||
val type: String, // HTTP, CLASH, SS
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = false,
|
||||
|
||||
@Column(name = "host", length = 255)
|
||||
val host: String? = null, // HTTP 代理主机
|
||||
|
||||
@Column(name = "port")
|
||||
val port: Int? = null, // HTTP 代理端口
|
||||
|
||||
@Column(name = "username", length = 100)
|
||||
val username: String? = null, // HTTP 代理用户名(可选)
|
||||
|
||||
@Column(name = "password", length = 255)
|
||||
val password: String? = null, // HTTP 代理密码(BCrypt加密,可选)
|
||||
|
||||
@Column(name = "subscription_url", length = 500)
|
||||
val subscriptionUrl: String? = null, // 订阅链接(Clash/SS)
|
||||
|
||||
@Column(name = "subscription_config", columnDefinition = "TEXT")
|
||||
val subscriptionConfig: String? = null, // 订阅配置内容(JSON格式)
|
||||
|
||||
@Column(name = "last_subscription_update")
|
||||
val lastSubscriptionUpdate: Long? = null, // 最后订阅更新时间
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.ProxyConfig
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 代理配置 Repository
|
||||
*/
|
||||
@Repository
|
||||
interface ProxyConfigRepository : JpaRepository<ProxyConfig, Long> {
|
||||
/**
|
||||
* 查找启用的代理配置
|
||||
*/
|
||||
fun findByEnabledTrue(): ProxyConfig?
|
||||
|
||||
/**
|
||||
* 根据类型查找启用的代理配置
|
||||
*/
|
||||
fun findByTypeAndEnabledTrue(type: String): ProxyConfig?
|
||||
|
||||
/**
|
||||
* 根据类型查找代理配置(无论是否启用)
|
||||
*/
|
||||
fun findByType(type: String): ProxyConfig?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.BeansException
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* API 健康检查服务
|
||||
*/
|
||||
@Service
|
||||
class ApiHealthCheckService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
@Value("\${polymarket.data-api.base-url}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private val polymarketWsUrl: String
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单推送服务(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getOrderPushService(): OrderPushService? {
|
||||
return try {
|
||||
applicationContext?.getBean(OrderPushService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取跟单 WebSocket 服务(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getCopyTradingWebSocketService(): CopyTradingWebSocketService? {
|
||||
return try {
|
||||
applicationContext?.getBean(CopyTradingWebSocketService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||
|
||||
/**
|
||||
* 检查所有 API 的健康状态
|
||||
*/
|
||||
suspend fun checkAllApis(): ApiHealthCheckResponse {
|
||||
val apis = mutableListOf<ApiHealthCheckDto>()
|
||||
|
||||
// 并行检查所有 API
|
||||
coroutineScope {
|
||||
val jobs = listOf(
|
||||
async { checkClobApi() },
|
||||
async { checkDataApi() },
|
||||
async { checkGammaApi() },
|
||||
async { checkPolygonRpc() },
|
||||
async { checkPolymarketWebSocket() }
|
||||
)
|
||||
|
||||
jobs.awaitAll().forEach { result ->
|
||||
apis.add(result)
|
||||
}
|
||||
}
|
||||
|
||||
return ApiHealthCheckResponse(apis = apis)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket CLOB API
|
||||
*/
|
||||
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "$clobBaseUrl/"
|
||||
checkApi("Polymarket CLOB API", url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket Data API
|
||||
*/
|
||||
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "$dataApiBaseUrl/"
|
||||
checkApi("Polymarket Data API", url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket Gamma API
|
||||
* 使用 /markets 接口检查 API 可用性(调用实际的业务接口)
|
||||
*/
|
||||
private suspend fun checkGammaApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val client = createClient()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
||||
val url = "$gammaBaseUrl/markets"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
if (response.isSuccessful) {
|
||||
// 检查响应体是否为有效的 JSON 数组(即使为空数组也可以)
|
||||
val responseBody = response.body?.string()
|
||||
if (responseBody != null && (responseBody.trim().startsWith("[") || responseBody.trim()
|
||||
.startsWith("{"))
|
||||
) {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Gamma API",
|
||||
url = url,
|
||||
status = "success",
|
||||
message = "连接成功",
|
||||
responseTime = responseTime
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Gamma API",
|
||||
url = url,
|
||||
status = "error",
|
||||
message = "响应格式不正确",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Gamma API",
|
||||
url = url,
|
||||
status = "error",
|
||||
message = "HTTP ${response.code}: ${response.message}",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 Polymarket Gamma API 失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Gamma API",
|
||||
url = "$gammaBaseUrl/markets",
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polygon RPC
|
||||
*/
|
||||
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Polygon RPC",
|
||||
url = "未配置",
|
||||
status = "skipped",
|
||||
message = "未配置 Polygon RPC URL"
|
||||
)
|
||||
}
|
||||
|
||||
val url = polygonRpcUrl
|
||||
checkJsonRpcApi("Polygon RPC", url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket WebSocket 连接状态
|
||||
* 不显示延时,只显示连接状态
|
||||
*/
|
||||
private suspend fun checkPolymarketWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
// 检查订单推送服务的连接状态
|
||||
val orderPushService = getOrderPushService()
|
||||
val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||
val orderPushConnected = orderPushStatuses.values.any { it }
|
||||
val orderPushTotal = orderPushStatuses.size
|
||||
val orderPushConnectedCount = orderPushStatuses.values.count { it }
|
||||
|
||||
// 检查跟单 WebSocket 服务的连接状态
|
||||
val copyTradingWebSocketService = getCopyTradingWebSocketService()
|
||||
val copyTradingStatuses = copyTradingWebSocketService?.getConnectionStatuses() ?: emptyMap()
|
||||
val copyTradingConnected = copyTradingStatuses.values.any { it }
|
||||
val copyTradingTotal = copyTradingStatuses.size
|
||||
val copyTradingConnectedCount = copyTradingStatuses.values.count { it }
|
||||
|
||||
// 计算总体状态
|
||||
val totalConnections = orderPushTotal + copyTradingTotal
|
||||
val connectedConnections = orderPushConnectedCount + copyTradingConnectedCount
|
||||
|
||||
val url = polymarketWsUrl
|
||||
val hasAnyConnection = orderPushConnected || copyTradingConnected
|
||||
|
||||
if (totalConnections == 0) {
|
||||
// 没有配置任何 WebSocket 连接
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
status = "skipped",
|
||||
message = "未配置 WebSocket 连接"
|
||||
)
|
||||
} else if (hasAnyConnection) {
|
||||
// 至少有一个连接是活跃的
|
||||
val message = if (connectedConnections == totalConnections) {
|
||||
"所有连接正常 ($connectedConnections/$totalConnections)"
|
||||
} else {
|
||||
"部分连接正常 ($connectedConnections/$totalConnections)"
|
||||
}
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
status = "success",
|
||||
message = message
|
||||
// 不设置 responseTime,WebSocket 不显示延时
|
||||
)
|
||||
} else {
|
||||
// 所有连接都断开
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
status = "error",
|
||||
message = "所有连接断开 ($connectedConnections/$totalConnections)"
|
||||
// 不设置 responseTime,WebSocket 不显示延时
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 Polymarket WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = polymarketWsUrl,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查普通 HTTP API
|
||||
*/
|
||||
private suspend fun checkApi(name: String, url: String): ApiHealthCheckDto {
|
||||
return try {
|
||||
val client = createClient()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
if (response.isSuccessful) {
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "success",
|
||||
message = "连接成功",
|
||||
responseTime = responseTime
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "error",
|
||||
message = "HTTP ${response.code}: ${response.message}",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 API 失败: $name ($url)", e)
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 JSON-RPC API(如 Polygon RPC)
|
||||
*/
|
||||
private suspend fun checkJsonRpcApi(name: String, url: String): ApiHealthCheckDto {
|
||||
return try {
|
||||
val client = createClient()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// 发送一个简单的 JSON-RPC 请求(获取链 ID)
|
||||
val jsonRpcRequest = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "eth_chainId",
|
||||
"params": [],
|
||||
"id": 1
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
?: "application/json".toMediaType()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(okhttp3.RequestBody.create(mediaType, jsonRpcRequest))
|
||||
.header("Content-Type", "application/json")
|
||||
.build()
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val responseBody = response.body?.string()
|
||||
if (responseBody != null && responseBody.contains("\"result\"")) {
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "success",
|
||||
message = "连接成功",
|
||||
responseTime = responseTime
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "error",
|
||||
message = "响应格式不正确",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "error",
|
||||
message = "HTTP ${response.code}: ${response.message}",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 JSON-RPC API 失败: $name ($url)", e)
|
||||
ApiHealthCheckDto(
|
||||
name = name,
|
||||
url = url,
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import java.math.BigInteger
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${ethereum.rpc.url:}")
|
||||
private val ethereumRpcUrl: String,
|
||||
@Value("\${polygon.rpc.url:}")
|
||||
private val polygonRpcUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
@@ -67,11 +67,11 @@ class BlockchainService(
|
||||
.create(PolymarketDataApi::class.java)
|
||||
}
|
||||
|
||||
private val ethereumRpcApi: EthereumRpcApi? by lazy {
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
private val polygonRpcApi: EthereumRpcApi? by lazy {
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(ethereumRpcUrl)
|
||||
retrofitFactory.createEthereumRpcApi(polygonRpcUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +84,12 @@ class BlockchainService(
|
||||
suspend fun getProxyAddress(walletAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Ethereum RPC URL,无法获取代理地址")
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法获取代理地址。请在配置文件中设置 ethereum.rpc.url 环境变量"))
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法获取代理地址")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法获取代理地址。请在配置文件中设置 polygon.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
// 计算函数选择器
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
|
||||
@@ -138,7 +138,7 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 查询账户 USDC 余额
|
||||
* 通过 Ethereum RPC 查询 ERC-20 代币余额
|
||||
* 通过 Polygon RPC 查询 ERC-20 代币余额
|
||||
* @param walletAddress 钱包地址(用于日志记录)
|
||||
* @param proxyAddress 代理地址(必须提供)
|
||||
* 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据)
|
||||
@@ -146,9 +146,9 @@ class BlockchainService(
|
||||
suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Ethereum RPC URL,无法查询 USDC 余额")
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法查询 USDC 余额。请在配置文件中设置 ethereum.rpc.url 环境变量"))
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法查询 USDC 余额")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法查询 USDC 余额。请在配置文件中设置 polygon.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
// 检查代理地址是否为空
|
||||
@@ -171,7 +171,7 @@ class BlockchainService(
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
*/
|
||||
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
// 构建 ERC-20 balanceOf 函数调用
|
||||
// function signature: balanceOf(address) -> bytes4(0x70a08231)
|
||||
@@ -263,12 +263,12 @@ class BlockchainService(
|
||||
suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Ethereum RPC URL,无法计算 tokenId")
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法计算 tokenId"))
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法计算 tokenId")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法计算 tokenId"))
|
||||
}
|
||||
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
// 验证 outcomeIndex
|
||||
if (outcomeIndex < 0) {
|
||||
@@ -422,12 +422,12 @@ class BlockchainService(
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Ethereum RPC URL,无法赎回仓位")
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法赎回仓位"))
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Polygon RPC URL,无法赎回仓位")
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法赎回仓位"))
|
||||
}
|
||||
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
// 验证参数
|
||||
if (indexSets.isEmpty()) {
|
||||
@@ -650,7 +650,7 @@ class BlockchainService(
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
private suspend fun getProxyNonce(proxyAddress: String): Result<BigInteger> {
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
// Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取
|
||||
val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()")
|
||||
@@ -685,7 +685,7 @@ class BlockchainService(
|
||||
* 获取交易 nonce
|
||||
*/
|
||||
private suspend fun getTransactionCount(address: String): Result<BigInteger> {
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionCount",
|
||||
@@ -713,7 +713,7 @@ class BlockchainService(
|
||||
* 获取 gas price
|
||||
*/
|
||||
private suspend fun getGasPrice(): Result<BigInteger> {
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_gasPrice",
|
||||
@@ -815,11 +815,11 @@ class BlockchainService(
|
||||
*/
|
||||
suspend fun getTransactionDetails(txHash: String): Result<String> {
|
||||
return try {
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL"))
|
||||
if (polygonRpcUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("未配置 Polygon RPC URL"))
|
||||
}
|
||||
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
|
||||
|
||||
// 查询交易
|
||||
val txRequest = JsonRpcRequest(
|
||||
|
||||
+39
@@ -130,6 +130,45 @@ class CopyTradingWebSocketService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连所有 Leader 的 WebSocket 连接(用于代理配置变更时)
|
||||
*/
|
||||
fun reconnectAll() {
|
||||
logger.info("重连所有 Leader 的 WebSocket 连接(代理配置已更新)")
|
||||
val leaderIds = leaderClients.keys.toList()
|
||||
val leaderAddressesMap = leaderAddresses.toMap()
|
||||
|
||||
leaderIds.forEach { leaderId ->
|
||||
try {
|
||||
// 断开旧连接
|
||||
val oldClient = leaderClients.remove(leaderId)
|
||||
oldClient?.closeConnection()
|
||||
|
||||
// 重新连接
|
||||
val leaderAddress = leaderAddressesMap[leaderId]
|
||||
if (leaderAddress != null) {
|
||||
// 重新创建 Leader 对象(简化版,只需要地址)
|
||||
val leader = Leader(
|
||||
id = leaderId,
|
||||
leaderAddress = leaderAddress,
|
||||
leaderName = null,
|
||||
category = null
|
||||
)
|
||||
addLeader(leader)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("重连 Leader $leaderId 失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Leader 的连接状态
|
||||
*/
|
||||
fun getConnectionStatuses(): Map<Long, Boolean> {
|
||||
return leaderClients.mapValues { (_, client) -> client.isConnected() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅用户交易频道
|
||||
*/
|
||||
|
||||
@@ -419,6 +419,36 @@ class OrderPushService(
|
||||
// 如果没有订阅者了,可以考虑关闭连接(但暂时保持连接,以便后续订阅)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连所有账户的 WebSocket 连接(用于代理配置变更时)
|
||||
*/
|
||||
fun reconnectAllAccounts() {
|
||||
logger.info("重连所有账户的 WebSocket 连接(代理配置已更新)")
|
||||
val accountIds = accountConnections.keys.toList()
|
||||
accountIds.forEach { accountId ->
|
||||
try {
|
||||
// 断开旧连接
|
||||
val oldClient = accountConnections.remove(accountId)
|
||||
oldClient?.closeConnection()
|
||||
|
||||
// 重新连接
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
if (account != null && hasApiCredentials(account) && account.isEnabled) {
|
||||
connectAccount(account)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("重连账户 $accountId 失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有账户的连接状态
|
||||
*/
|
||||
fun getConnectionStatuses(): Map<Long, Boolean> {
|
||||
return accountConnections.mapValues { (_, client) -> client.isConnected() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定账户的连接
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.ProxyConfig
|
||||
import com.wrbug.polymarketbot.repository.ProxyConfigRepository
|
||||
import com.wrbug.polymarketbot.util.ProxyConfigProvider
|
||||
import com.wrbug.polymarketbot.util.TrustAllHostnameVerifier
|
||||
import com.wrbug.polymarketbot.util.createSSLSocketFactory
|
||||
import okhttp3.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.BeansException
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 代理配置服务
|
||||
*/
|
||||
@Service
|
||||
class ProxyConfigService(
|
||||
private val proxyConfigRepository: ProxyConfigRepository
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ProxyConfigService::class.java)
|
||||
|
||||
/**
|
||||
* 获取当前代理配置
|
||||
* 返回 HTTP 类型的代理配置(无论是否启用),以便前端可以显示和编辑配置
|
||||
*/
|
||||
fun getProxyConfig(): ProxyConfigDto? {
|
||||
// 优先查找 HTTP 类型的代理配置(无论是否启用)
|
||||
val config = proxyConfigRepository.findByType("HTTP")
|
||||
?: return null
|
||||
|
||||
// 如果配置是启用的,更新 ProxyConfigProvider
|
||||
if (config.enabled) {
|
||||
ProxyConfigProvider.setProxyConfig(config)
|
||||
} else {
|
||||
// 如果配置是禁用的,清除 ProxyConfigProvider(不使用代理)
|
||||
ProxyConfigProvider.setProxyConfig(null)
|
||||
}
|
||||
|
||||
return toDto(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化代理配置(应用启动时调用)
|
||||
*/
|
||||
fun initProxyConfig() {
|
||||
val config = proxyConfigRepository.findByEnabledTrue()
|
||||
ProxyConfigProvider.setProxyConfig(config)
|
||||
if (config != null) {
|
||||
logger.info("初始化代理配置:type=${config.type}, host=${config.host}, port=${config.port}, enabled=${config.enabled}")
|
||||
} else {
|
||||
logger.info("未找到启用的代理配置")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有代理配置(用于管理)
|
||||
*/
|
||||
fun getAllProxyConfigs(): List<ProxyConfigDto> {
|
||||
return proxyConfigRepository.findAll().map { toDto(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新 HTTP 代理配置
|
||||
*/
|
||||
@Transactional
|
||||
fun saveHttpProxyConfig(request: HttpProxyConfigRequest): Result<ProxyConfigDto> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (request.host.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理主机不能为空"))
|
||||
}
|
||||
if (request.port <= 0 || request.port > 65535) {
|
||||
return Result.failure(IllegalArgumentException("代理端口必须在 1-65535 之间"))
|
||||
}
|
||||
|
||||
// 查找现有的 HTTP 代理配置(无论是否启用)
|
||||
val existing = proxyConfigRepository.findByType("HTTP")
|
||||
|
||||
val config = if (existing != null) {
|
||||
// 更新现有配置
|
||||
val password = if (request.password != null && request.password.isNotBlank()) {
|
||||
request.password // 明文存储(与 Account 实体保持一致)
|
||||
} else {
|
||||
existing.password // 如果密码为空,保持原密码
|
||||
}
|
||||
|
||||
existing.copy(
|
||||
enabled = request.enabled,
|
||||
host = request.host,
|
||||
port = request.port,
|
||||
username = request.username?.takeIf { it.isNotBlank() },
|
||||
password = password,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
// 创建新配置
|
||||
val password = if (request.password != null && request.password.isNotBlank()) {
|
||||
request.password // 明文存储(与 Account 实体保持一致)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
ProxyConfig(
|
||||
type = "HTTP",
|
||||
enabled = request.enabled,
|
||||
host = request.host,
|
||||
port = request.port,
|
||||
username = request.username?.takeIf { it.isNotBlank() },
|
||||
password = password
|
||||
)
|
||||
}
|
||||
|
||||
val saved = proxyConfigRepository.save(config)
|
||||
logger.info("保存 HTTP 代理配置成功:host=${saved.host}, port=${saved.port}, enabled=${saved.enabled}")
|
||||
|
||||
// 更新 ProxyConfigProvider
|
||||
if (saved.enabled) {
|
||||
ProxyConfigProvider.setProxyConfig(saved)
|
||||
} else {
|
||||
// 如果禁用了代理,清除配置
|
||||
ProxyConfigProvider.setProxyConfig(null)
|
||||
}
|
||||
|
||||
// 触发 WebSocket 重连(使用新代理配置)
|
||||
triggerWebSocketReconnect()
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("保存 HTTP 代理配置失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理是否可用
|
||||
* 使用配置的代理请求 Polymarket 健康检查接口
|
||||
*/
|
||||
fun checkProxy(): ProxyCheckResponse {
|
||||
return try {
|
||||
val config = proxyConfigRepository.findByEnabledTrue()
|
||||
?: return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "未配置代理或代理未启用"
|
||||
)
|
||||
|
||||
if (config.type != "HTTP") {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
|
||||
)
|
||||
}
|
||||
|
||||
if (config.host == null || config.port == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理配置不完整:缺少主机或端口"
|
||||
)
|
||||
}
|
||||
|
||||
// 创建代理
|
||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
|
||||
|
||||
// 创建 OkHttpClient
|
||||
val clientBuilder = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
// 配置 SSL:信任所有证书(用于代理连接)
|
||||
clientBuilder.createSSLSocketFactory()
|
||||
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
|
||||
|
||||
// 如果配置了用户名和密码,添加代理认证
|
||||
if (config.username != null && config.password != null) {
|
||||
clientBuilder.proxyAuthenticator { _, response ->
|
||||
val credential = okhttp3.Credentials.basic(config.username, config.password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
val client = clientBuilder.build()
|
||||
|
||||
// 请求 Polymarket 健康检查接口
|
||||
val request = Request.Builder()
|
||||
.url("https://data-api.polymarket.com/")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
if (response.isSuccessful && responseBody != null) {
|
||||
// 检查响应内容是否为 {"data": "OK"}
|
||||
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
|
||||
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
|
||||
ProxyCheckResponse.create(
|
||||
success = true,
|
||||
message = "代理连接成功",
|
||||
responseTime = responseTime
|
||||
)
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理连接成功,但响应格式不正确:$responseBody",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("代理检查异常", e)
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteProxyConfig(id: Long): Result<Unit> {
|
||||
return try {
|
||||
val config = proxyConfigRepository.findById(id)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("代理配置不存在"))
|
||||
|
||||
val wasEnabled = config.enabled
|
||||
proxyConfigRepository.delete(config)
|
||||
logger.info("删除代理配置成功:id=$id, type=${config.type}")
|
||||
|
||||
// 如果删除的是启用的代理配置,清除 ProxyConfigProvider
|
||||
if (wasEnabled) {
|
||||
ProxyConfigProvider.setProxyConfig(null)
|
||||
// 触发 WebSocket 重连(使用新配置,即无代理)
|
||||
triggerWebSocketReconnect()
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除代理配置失败:id=$id", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发所有 WebSocket 重连(使用新代理配置)
|
||||
*/
|
||||
private fun triggerWebSocketReconnect() {
|
||||
try {
|
||||
val context = applicationContext ?: return
|
||||
|
||||
// 重连订单推送服务的 WebSocket 连接
|
||||
try {
|
||||
val orderPushService = context.getBean(OrderPushService::class.java)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
orderPushService.reconnectAllAccounts()
|
||||
logger.info("已触发订单推送服务 WebSocket 重连")
|
||||
} catch (e: Exception) {
|
||||
logger.error("触发订单推送服务重连失败", e)
|
||||
}
|
||||
}
|
||||
} catch (e: BeansException) {
|
||||
logger.debug("订单推送服务未找到,跳过重连", e)
|
||||
}
|
||||
|
||||
// 重连跟单 WebSocket 服务的连接
|
||||
try {
|
||||
val copyTradingWebSocketService = context.getBean(CopyTradingWebSocketService::class.java)
|
||||
try {
|
||||
copyTradingWebSocketService.reconnectAll()
|
||||
logger.info("已触发跟单 WebSocket 服务重连")
|
||||
} catch (e: Exception) {
|
||||
logger.error("触发跟单 WebSocket 服务重连失败", e)
|
||||
}
|
||||
} catch (e: BeansException) {
|
||||
logger.debug("跟单 WebSocket 服务未找到,跳过重连", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("触发 WebSocket 重连失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO(不包含密码)
|
||||
*/
|
||||
private fun toDto(config: ProxyConfig): ProxyConfigDto {
|
||||
return ProxyConfigDto(
|
||||
id = config.id,
|
||||
type = config.type,
|
||||
enabled = config.enabled,
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
username = config.username,
|
||||
subscriptionUrl = config.subscriptionUrl,
|
||||
lastSubscriptionUpdate = config.lastSubscriptionUpdate,
|
||||
createdAt = config.createdAt,
|
||||
updatedAt = config.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,51 +12,44 @@ import javax.net.ssl.*
|
||||
|
||||
/**
|
||||
* 获取代理配置(用于 WebSocket 和 HTTP 请求)
|
||||
* 从数据库读取代理配置
|
||||
* @return Proxy 对象,如果未启用代理则返回 null
|
||||
*/
|
||||
fun getProxyConfig(): Proxy? {
|
||||
if (getEnv("ENABLE_PROXY") != "1") {
|
||||
return null
|
||||
}
|
||||
val host = getEnv("PROXY_HOST").ifEmpty { "127.0.0.1" }
|
||||
val port = getEnv("PROXY_PORT").toIntOrNull() ?: 8888
|
||||
return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
|
||||
return ProxyConfigProvider.getProxy()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建OkHttpClient客户端
|
||||
* 自动应用代理配置(从数据库读取)
|
||||
* @return OkHttpClient.Builder
|
||||
*/
|
||||
fun createClient() = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.httpProxy("127.0.0.1", 8888)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
fun createClient(): OkHttpClient.Builder {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
|
||||
/**
|
||||
* 为OkHttpClient添加HTTP代理支持
|
||||
* @param hostname 代理服务器地址
|
||||
* @param port 代理服务器端口
|
||||
* @param user 代理用户名(可选)
|
||||
* @param password 代理密码(可选)
|
||||
* @return OkHttpClient.Builder
|
||||
*/
|
||||
fun OkHttpClient.Builder.httpProxy(
|
||||
hostname: String, port: Int, user: String = "", password: String = ""
|
||||
): OkHttpClient.Builder {
|
||||
if (getEnv("ENABLE_PROXY") != "1") {
|
||||
return this
|
||||
}
|
||||
return apply {
|
||||
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(hostname, port)))
|
||||
createSSLSocketFactory()
|
||||
if (user.isNotEmpty() && password.isNotEmpty()) {
|
||||
proxyAuthenticator { _, res ->
|
||||
val credential: String = Credentials.basic(user, password)
|
||||
res.request.newBuilder().header("Proxy-Authorization", credential).build()
|
||||
// 从数据库读取代理配置
|
||||
val dbProxy = ProxyConfigProvider.getProxy()
|
||||
if (dbProxy != null) {
|
||||
builder.proxy(dbProxy)
|
||||
builder.createSSLSocketFactory()
|
||||
|
||||
// 如果配置了用户名和密码,添加代理认证
|
||||
val username = ProxyConfigProvider.getProxyUsername()
|
||||
val password = ProxyConfigProvider.getProxyPassword()
|
||||
if (username != null && password != null) {
|
||||
builder.proxyAuthenticator { _, response ->
|
||||
val credential = Credentials.basic(username, password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +57,15 @@ fun OkHttpClient.Builder.httpProxy(
|
||||
* @return OkHttpClient.Builder
|
||||
*/
|
||||
fun OkHttpClient.Builder.createSSLSocketFactory(): OkHttpClient.Builder {
|
||||
runCatching {
|
||||
val sc: SSLContext = SSLContext.getInstance("TLS")
|
||||
sc.init(null, arrayOf<TrustManager>(TrustAllManager()), SecureRandom())
|
||||
this.sslSocketFactory(sc.socketFactory, TrustAllManager())
|
||||
return apply {
|
||||
try {
|
||||
val sc: SSLContext = SSLContext.getInstance("TLS")
|
||||
sc.init(null, arrayOf<TrustManager>(TrustAllManager()), SecureRandom())
|
||||
sslSocketFactory(sc.socketFactory, TrustAllManager())
|
||||
} catch (t: Error) {
|
||||
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.wrbug.polymarketbot.entity.ProxyConfig
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
/**
|
||||
* 代理配置提供者(单例)
|
||||
* 用于在工具函数中获取代理配置
|
||||
*/
|
||||
object ProxyConfigProvider {
|
||||
@Volatile
|
||||
private var proxyConfig: ProxyConfig? = null
|
||||
|
||||
/**
|
||||
* 设置代理配置(由 ProxyConfigService 调用)
|
||||
*/
|
||||
fun setProxyConfig(config: ProxyConfig?) {
|
||||
proxyConfig = config
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理配置
|
||||
*/
|
||||
fun getProxyConfig(): ProxyConfig? = proxyConfig
|
||||
|
||||
/**
|
||||
* 获取 Proxy 对象(用于 OkHttp)
|
||||
*/
|
||||
fun getProxy(): Proxy? {
|
||||
val config = proxyConfig ?: return null
|
||||
if (!config.enabled) {
|
||||
return null
|
||||
}
|
||||
if (config.type != "HTTP") {
|
||||
return null // 目前只支持 HTTP 代理
|
||||
}
|
||||
if (config.host == null || config.port == null) {
|
||||
return null
|
||||
}
|
||||
return Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理用户名
|
||||
*/
|
||||
fun getProxyUsername(): String? = proxyConfig?.username
|
||||
|
||||
/**
|
||||
* 获取代理密码
|
||||
*/
|
||||
fun getProxyPassword(): String? = proxyConfig?.password
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory
|
||||
/**
|
||||
* Polymarket WebSocket 客户端(使用 OkHttp 实现)
|
||||
* 用于连接到 Polymarket RTDS
|
||||
* 支持代理配置(通过环境变量 ENABLE_PROXY、PROXY_HOST、PROXY_PORT 控制)
|
||||
* 支持代理配置(从数据库读取)
|
||||
*/
|
||||
class PolymarketWebSocketClient(
|
||||
private val url: String,
|
||||
|
||||
@@ -36,10 +36,10 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
|
||||
# Ethereum RPC 配置(用于查询链上余额)
|
||||
# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
|
||||
# Polygon RPC 配置(用于查询链上余额)
|
||||
# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
|
||||
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
||||
ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com}
|
||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# 仓位推送配置
|
||||
# 轮询间隔(毫秒),默认3秒
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
-- 添加 outcome_index 字段到订单跟踪表和卖出匹配记录表
|
||||
-- 支持多元市场(不限于YES/NO)
|
||||
|
||||
-- 1. 添加 outcome_index 字段到 copy_order_tracking 表
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD COLUMN outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场' AFTER side;
|
||||
|
||||
-- 2. 添加 outcome_index 字段到 sell_match_record 表
|
||||
ALTER TABLE sell_match_record
|
||||
ADD COLUMN outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场' AFTER side;
|
||||
|
||||
-- 3. 添加索引以优化查询性能
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD INDEX idx_market_outcome (market_id, outcome_index);
|
||||
|
||||
ALTER TABLE sell_match_record
|
||||
ADD INDEX idx_market_outcome (market_id, outcome_index);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 创建用户表(用于JWT登录鉴权)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(唯一)',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表(JWT登录鉴权)';
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 添加 is_default 字段到 users 表
|
||||
ALTER TABLE users
|
||||
ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户(首次创建的用户)';
|
||||
|
||||
-- 为默认账户添加索引
|
||||
ALTER TABLE users
|
||||
ADD INDEX idx_is_default (is_default);
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
-- 创建账户表
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_accounts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)',
|
||||
wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)',
|
||||
api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)',
|
||||
account_name VARCHAR(100) NULL COMMENT '账户名称',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_wallet_address (wallet_address),
|
||||
INDEX idx_is_default (is_default)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表';
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
-- ============================================
|
||||
-- 数据库初始化脚本(合并所有迁移版本)
|
||||
-- ============================================
|
||||
|
||||
-- ============================================
|
||||
-- 1. 创建账户表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_accounts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)',
|
||||
wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)',
|
||||
proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)',
|
||||
api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)',
|
||||
api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)',
|
||||
api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)',
|
||||
account_name VARCHAR(100) NULL COMMENT '账户名称',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户',
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_wallet_address (wallet_address),
|
||||
INDEX idx_is_default (is_default)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表';
|
||||
|
||||
-- ============================================
|
||||
-- 2. 创建被跟单者(Leader)表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_leaders (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_address VARCHAR(42) NOT NULL UNIQUE COMMENT '被跟单者的钱包地址',
|
||||
leader_name VARCHAR(100) NULL COMMENT '被跟单者名称',
|
||||
category VARCHAR(20) NULL COMMENT '分类筛选(sports/crypto),null表示不筛选',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_leader_address (leader_address),
|
||||
INDEX idx_category (category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='被跟单者表';
|
||||
|
||||
-- ============================================
|
||||
-- 3. 创建跟单模板表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_templates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_name VARCHAR(100) NOT NULL UNIQUE COMMENT '模板名称',
|
||||
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单金额模式(RATIO/FIXED)',
|
||||
copy_ratio DECIMAL(10, 2) NOT NULL DEFAULT 1.00 COMMENT '跟单比例(仅在copyMode=RATIO时生效)',
|
||||
fixed_amount DECIMAL(20, 8) NULL COMMENT '固定跟单金额(仅在copyMode=FIXED时生效)',
|
||||
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.00000000 COMMENT '单笔订单最大金额(USDC)',
|
||||
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '单笔订单最小金额(USDC)',
|
||||
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.00000000 COMMENT '每日最大亏损限制(USDC)',
|
||||
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '每日最大跟单订单数',
|
||||
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.00 COMMENT '价格容忍度(百分比,0-100)',
|
||||
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '跟单延迟(秒,默认0立即跟单)',
|
||||
poll_interval_seconds INT NOT NULL DEFAULT 5 COMMENT '轮询间隔(秒,仅在WebSocket不可用时使用)',
|
||||
use_websocket BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否优先使用WebSocket推送',
|
||||
websocket_reconnect_interval INT NOT NULL DEFAULT 5000 COMMENT 'WebSocket重连间隔(毫秒)',
|
||||
websocket_max_retries INT NOT NULL DEFAULT 10 COMMENT 'WebSocket最大重试次数',
|
||||
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持跟单卖出',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_template_name (template_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单模板表';
|
||||
|
||||
-- ============================================
|
||||
-- 4. 创建跟单关系表(钱包-模板关联,多对多关系)
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS copy_trading (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
|
||||
template_id BIGINT NOT NULL COMMENT '模板ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
UNIQUE KEY uk_account_template_leader (account_id, template_id, leader_id),
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_template_id (template_id),
|
||||
INDEX idx_leader_id (leader_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
FOREIGN KEY (account_id) REFERENCES copy_trading_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES copy_trading_templates(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单关系表(钱包-模板关联)';
|
||||
|
||||
-- ============================================
|
||||
-- 5. 创建订单跟踪表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS copy_order_tracking (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
|
||||
account_id BIGINT NOT NULL COMMENT '账户ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
template_id BIGINT NOT NULL COMMENT '模板ID',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
|
||||
side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
|
||||
buy_order_id VARCHAR(100) NOT NULL COMMENT '跟单买入订单ID',
|
||||
leader_buy_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 买入交易ID',
|
||||
quantity DECIMAL(20, 8) NOT NULL COMMENT '买入数量',
|
||||
price DECIMAL(20, 8) NOT NULL COMMENT '买入价格',
|
||||
matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '已匹配卖出数量',
|
||||
remaining_quantity DECIMAL(20, 8) NOT NULL COMMENT '剩余未匹配数量',
|
||||
status VARCHAR(20) NOT NULL COMMENT '状态:filled, fully_matched, partially_matched',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_remaining (remaining_quantity, status),
|
||||
INDEX idx_market_side (market_id, side),
|
||||
INDEX idx_market_outcome (market_id, outcome_index),
|
||||
INDEX idx_leader_trade (leader_id, leader_buy_trade_id),
|
||||
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单跟踪表';
|
||||
|
||||
-- ============================================
|
||||
-- 6. 创建卖出匹配记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS sell_match_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
|
||||
sell_order_id VARCHAR(100) NOT NULL COMMENT '跟单卖出订单ID',
|
||||
leader_sell_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 卖出交易ID',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
|
||||
side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
|
||||
total_matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '总匹配数量',
|
||||
sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格',
|
||||
total_realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '总已实现盈亏',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_sell_order (sell_order_id),
|
||||
INDEX idx_market_outcome (market_id, outcome_index),
|
||||
INDEX idx_leader_trade (leader_sell_trade_id),
|
||||
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卖出匹配记录表';
|
||||
|
||||
-- ============================================
|
||||
-- 7. 创建匹配明细表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS sell_match_detail (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
match_record_id BIGINT NOT NULL COMMENT '关联 sell_match_record.id',
|
||||
tracking_id BIGINT NOT NULL COMMENT '关联 copy_order_tracking.id',
|
||||
buy_order_id VARCHAR(100) NOT NULL COMMENT '买入订单ID',
|
||||
matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '匹配的数量',
|
||||
buy_price DECIMAL(20, 8) NOT NULL COMMENT '买入价格',
|
||||
sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格',
|
||||
realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '盈亏 = (sell_price - buy_price) * matched_quantity',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
INDEX idx_match_record (match_record_id),
|
||||
INDEX idx_tracking (tracking_id),
|
||||
INDEX idx_buy_order (buy_order_id),
|
||||
FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配明细表';
|
||||
|
||||
-- ============================================
|
||||
-- 8. 创建已处理交易表(用于去重)
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS processed_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID(trade.id,唯一标识)',
|
||||
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL',
|
||||
source VARCHAR(20) NOT NULL COMMENT '数据来源:websocket 或 polling',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS' COMMENT '处理状态:SUCCESS(成功)、FAILED(失败)',
|
||||
processed_at BIGINT NOT NULL COMMENT '处理时间(毫秒时间戳)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id),
|
||||
INDEX idx_processed_at (processed_at),
|
||||
INDEX idx_leader_id (leader_id),
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已处理交易表(用于去重)';
|
||||
|
||||
-- ============================================
|
||||
-- 9. 创建失败交易记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS failed_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID',
|
||||
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL',
|
||||
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
|
||||
account_id BIGINT NOT NULL COMMENT '账户ID',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
|
||||
side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
price VARCHAR(50) NOT NULL COMMENT '价格',
|
||||
size VARCHAR(50) NOT NULL COMMENT '数量',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
|
||||
failed_at BIGINT NOT NULL COMMENT '失败时间(毫秒时间戳)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
INDEX idx_leader_trade (leader_id, leader_trade_id),
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_failed_at (failed_at),
|
||||
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失败交易记录表';
|
||||
|
||||
-- ============================================
|
||||
-- 10. 创建用户表(用于JWT登录鉴权)
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(唯一)',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户(首次创建的用户)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_is_default (is_default)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表(JWT登录鉴权)';
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
-- 添加 API Secret 和 Passphrase 字段
|
||||
ALTER TABLE copy_trading_accounts
|
||||
ADD COLUMN api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)' AFTER api_key,
|
||||
ADD COLUMN api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)' AFTER api_secret;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- ============================================
|
||||
-- 创建代理配置表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
type VARCHAR(20) NOT NULL COMMENT '代理类型:HTTP, CLASH, SS',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用',
|
||||
host VARCHAR(255) NULL COMMENT '代理主机(HTTP代理)',
|
||||
port INT NULL COMMENT '代理端口(HTTP代理)',
|
||||
username VARCHAR(100) NULL COMMENT '代理用户名(HTTP代理,可选)',
|
||||
password VARCHAR(255) NULL COMMENT '代理密码(HTTP代理,可选,BCrypt加密)',
|
||||
subscription_url VARCHAR(500) NULL COMMENT '订阅链接(Clash/SS代理)',
|
||||
subscription_config TEXT NULL COMMENT '订阅配置内容(Clash/SS代理,JSON格式)',
|
||||
last_subscription_update BIGINT NULL COMMENT '最后订阅更新时间(毫秒时间戳)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_type (type),
|
||||
INDEX idx_enabled (enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代理配置表';
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
-- 添加代理地址字段到账户表
|
||||
ALTER TABLE copy_trading_accounts
|
||||
ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
-- 添加是否启用字段到账户表
|
||||
ALTER TABLE copy_trading_accounts
|
||||
ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)' AFTER is_default;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- 创建被跟单者(Leader)表
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_leaders (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_address VARCHAR(42) NOT NULL UNIQUE COMMENT '被跟单者的钱包地址',
|
||||
leader_name VARCHAR(100) NULL COMMENT '被跟单者名称',
|
||||
category VARCHAR(20) NULL COMMENT '分类筛选(sports/crypto),null表示不筛选',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_leader_address (leader_address),
|
||||
INDEX idx_category (category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='被跟单者表';
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
-- 创建跟单模板表
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_templates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_name VARCHAR(100) NOT NULL UNIQUE COMMENT '模板名称',
|
||||
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单金额模式(RATIO/FIXED)',
|
||||
copy_ratio DECIMAL(10, 2) NOT NULL DEFAULT 1.00 COMMENT '跟单比例(仅在copyMode=RATIO时生效)',
|
||||
fixed_amount DECIMAL(20, 8) NULL COMMENT '固定跟单金额(仅在copyMode=FIXED时生效)',
|
||||
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.00000000 COMMENT '单笔订单最大金额(USDC)',
|
||||
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '单笔订单最小金额(USDC)',
|
||||
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.00000000 COMMENT '每日最大亏损限制(USDC)',
|
||||
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '每日最大跟单订单数',
|
||||
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.00 COMMENT '价格容忍度(百分比,0-100)',
|
||||
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '跟单延迟(秒,默认0立即跟单)',
|
||||
poll_interval_seconds INT NOT NULL DEFAULT 5 COMMENT '轮询间隔(秒,仅在WebSocket不可用时使用)',
|
||||
use_websocket BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否优先使用WebSocket推送',
|
||||
websocket_reconnect_interval INT NOT NULL DEFAULT 5000 COMMENT 'WebSocket重连间隔(毫秒)',
|
||||
websocket_max_retries INT NOT NULL DEFAULT 10 COMMENT 'WebSocket最大重试次数',
|
||||
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持跟单卖出',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_template_name (template_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单模板表';
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 创建跟单关系表(钱包-模板关联,多对多关系)
|
||||
CREATE TABLE IF NOT EXISTS copy_trading (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
|
||||
template_id BIGINT NOT NULL COMMENT '模板ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
UNIQUE KEY uk_account_template_leader (account_id, template_id, leader_id),
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_template_id (template_id),
|
||||
INDEX idx_leader_id (leader_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
FOREIGN KEY (account_id) REFERENCES copy_trading_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES copy_trading_templates(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单关系表(钱包-模板关联)';
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
-- 创建订单跟踪表
|
||||
CREATE TABLE IF NOT EXISTS copy_order_tracking (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
|
||||
account_id BIGINT NOT NULL COMMENT '账户ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
template_id BIGINT NOT NULL COMMENT '模板ID',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
|
||||
side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
buy_order_id VARCHAR(100) NOT NULL COMMENT '跟单买入订单ID',
|
||||
leader_buy_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 买入交易ID',
|
||||
quantity DECIMAL(20, 8) NOT NULL COMMENT '买入数量',
|
||||
price DECIMAL(20, 8) NOT NULL COMMENT '买入价格',
|
||||
matched_quantity DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '已匹配卖出数量',
|
||||
remaining_quantity DECIMAL(20, 8) NOT NULL COMMENT '剩余未匹配数量',
|
||||
status VARCHAR(20) NOT NULL COMMENT '状态:filled, fully_matched, partially_matched',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_remaining (remaining_quantity, status),
|
||||
INDEX idx_market_side (market_id, side),
|
||||
INDEX idx_leader_trade (leader_id, leader_buy_trade_id),
|
||||
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单跟踪表';
|
||||
|
||||
-- 创建卖出匹配记录表
|
||||
CREATE TABLE IF NOT EXISTS sell_match_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
|
||||
sell_order_id VARCHAR(100) NOT NULL COMMENT '跟单卖出订单ID',
|
||||
leader_sell_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 卖出交易ID',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
|
||||
side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
total_matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '总匹配数量',
|
||||
sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格',
|
||||
total_realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '总已实现盈亏',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_sell_order (sell_order_id),
|
||||
INDEX idx_leader_trade (leader_sell_trade_id),
|
||||
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卖出匹配记录表';
|
||||
|
||||
-- 创建匹配明细表
|
||||
CREATE TABLE IF NOT EXISTS sell_match_detail (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
match_record_id BIGINT NOT NULL COMMENT '关联 sell_match_record.id',
|
||||
tracking_id BIGINT NOT NULL COMMENT '关联 copy_order_tracking.id',
|
||||
buy_order_id VARCHAR(100) NOT NULL COMMENT '买入订单ID',
|
||||
matched_quantity DECIMAL(20, 8) NOT NULL COMMENT '匹配的数量',
|
||||
buy_price DECIMAL(20, 8) NOT NULL COMMENT '买入价格',
|
||||
sell_price DECIMAL(20, 8) NOT NULL COMMENT '卖出价格',
|
||||
realized_pnl DECIMAL(20, 8) NOT NULL COMMENT '盈亏 = (sell_price - buy_price) * matched_quantity',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
INDEX idx_match_record (match_record_id),
|
||||
INDEX idx_tracking (tracking_id),
|
||||
INDEX idx_buy_order (buy_order_id),
|
||||
FOREIGN KEY (match_record_id) REFERENCES sell_match_record(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tracking_id) REFERENCES copy_order_tracking(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配明细表';
|
||||
|
||||
-- 创建已处理交易表(用于去重)
|
||||
CREATE TABLE IF NOT EXISTS processed_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID(trade.id,唯一标识)',
|
||||
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL',
|
||||
source VARCHAR(20) NOT NULL COMMENT '数据来源:websocket 或 polling',
|
||||
processed_at BIGINT NOT NULL COMMENT '处理时间(毫秒时间戳)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
UNIQUE KEY uk_leader_trade (leader_id, leader_trade_id),
|
||||
INDEX idx_processed_at (processed_at),
|
||||
INDEX idx_leader_id (leader_id),
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已处理交易表(用于去重)';
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
-- 修改已处理交易表,添加状态字段
|
||||
ALTER TABLE processed_trade
|
||||
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS' COMMENT '处理状态:SUCCESS(成功)、FAILED(失败)' AFTER source;
|
||||
|
||||
-- 创建失败交易记录表
|
||||
CREATE TABLE IF NOT EXISTS failed_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID',
|
||||
trade_type VARCHAR(10) NOT NULL COMMENT '交易类型:BUY 或 SELL',
|
||||
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
|
||||
account_id BIGINT NOT NULL COMMENT '账户ID',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
|
||||
side VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
price VARCHAR(50) NOT NULL COMMENT '价格',
|
||||
size VARCHAR(50) NOT NULL COMMENT '数量',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
|
||||
failed_at BIGINT NOT NULL COMMENT '失败时间(毫秒时间戳)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
INDEX idx_leader_trade (leader_id, leader_trade_id),
|
||||
INDEX idx_copy_trading (copy_trading_id),
|
||||
INDEX idx_failed_at (failed_at),
|
||||
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失败交易记录表';
|
||||
|
||||
@@ -25,6 +25,7 @@ import CopyTradingStatistics from './pages/CopyTradingStatistics'
|
||||
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
|
||||
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
|
||||
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -226,6 +227,7 @@ function App() {
|
||||
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
LinkOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
LogoutOutlined
|
||||
LogoutOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -101,6 +102,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理'
|
||||
},
|
||||
{
|
||||
key: '/system-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统管理'
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Badge, Spin, Row, Col } from 'antd'
|
||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface ProxyConfig {
|
||||
id?: number
|
||||
type: string
|
||||
enabled: boolean
|
||||
host?: string
|
||||
port?: number
|
||||
username?: string
|
||||
subscriptionUrl?: string
|
||||
lastSubscriptionUpdate?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number // 延迟(毫秒)
|
||||
}
|
||||
|
||||
interface ApiHealthStatus {
|
||||
name: string
|
||||
url: string
|
||||
status: string
|
||||
message: string
|
||||
responseTime?: number
|
||||
}
|
||||
|
||||
const SystemSettings: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [checkResult, setCheckResult] = useState<ProxyCheckResponse | null>(null)
|
||||
const [currentConfig, setCurrentConfig] = useState<ProxyConfig | null>(null)
|
||||
const [apiHealthStatus, setApiHealthStatus] = useState<ApiHealthStatus[]>([])
|
||||
const [checkingApiHealth, setCheckingApiHealth] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
checkApiHealth()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await apiService.proxyConfig.get()
|
||||
if (response.data.code === 0) {
|
||||
const data = response.data.data
|
||||
setCurrentConfig(data)
|
||||
if (data) {
|
||||
form.setFieldsValue({
|
||||
enabled: data.enabled,
|
||||
host: data.host || '',
|
||||
port: data.port || undefined,
|
||||
username: data.username || '',
|
||||
password: '', // 密码不预填充
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || '获取代理配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取代理配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.saveHttp({
|
||||
enabled: values.enabled || false,
|
||||
host: values.host,
|
||||
port: values.port,
|
||||
username: values.username || undefined,
|
||||
password: values.password || undefined, // 如果密码为空,则不更新密码
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success('保存代理配置成功。新配置将立即生效,已建立的 WebSocket 连接需要重新连接才能使用新代理。')
|
||||
fetchConfig()
|
||||
setCheckResult(null) // 清除检查结果
|
||||
// 自动刷新 API 健康状态,验证新配置是否生效
|
||||
setTimeout(() => {
|
||||
checkApiHealth()
|
||||
}, 1000)
|
||||
} else {
|
||||
message.error(response.data.msg || '保存代理配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '保存代理配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true)
|
||||
setCheckResult(null)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.check()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const result = response.data.data
|
||||
setCheckResult(result)
|
||||
if (result.success) {
|
||||
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`)
|
||||
} else {
|
||||
message.warning(`代理检查失败:${result.message}`)
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || '代理检查失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '代理检查失败')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkApiHealth = async () => {
|
||||
setCheckingApiHealth(true)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.checkApiHealth()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setApiHealthStatus(response.data.data.apis)
|
||||
} else {
|
||||
message.error(response.data.msg || 'API 健康检查失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'API 健康检查失败')
|
||||
} finally {
|
||||
setCheckingApiHealth(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === 'success') {
|
||||
return '#52c41a'
|
||||
} else if (status === 'skipped') {
|
||||
return '#999'
|
||||
} else {
|
||||
return '#ff4d4f'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
if (status === 'success') {
|
||||
return '正常'
|
||||
} else if (status === 'skipped') {
|
||||
return '未配置'
|
||||
} else {
|
||||
return '异常'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Title level={2} style={{ margin: 0 }}>系统管理</Title>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<GlobalOutlined />
|
||||
<span>API 健康状态</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: '16px' }}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={checkApiHealth}
|
||||
loading={checkingApiHealth}
|
||||
size="small"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={checkingApiHealth}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{apiHealthStatus.map((item, index) => (
|
||||
<Col
|
||||
key={index}
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={12}
|
||||
lg={8}
|
||||
xl={6}
|
||||
>
|
||||
{isMobile ? (
|
||||
// 移动端:一行显示,只保留名称、延迟、状态点(不显示文字)
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderLeft: `4px solid ${getStatusColor(item.status)}`,
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Space>
|
||||
{item.responseTime !== undefined && item.responseTime !== null && (
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
<Text strong style={{ color: '#1890ff' }}>{item.responseTime}ms</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Badge
|
||||
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
// 桌面端:保持原有卡片布局
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderLeft: `4px solid ${getStatusColor(item.status)}`,
|
||||
height: '100%'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Badge
|
||||
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
||||
text={getStatusText(item.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{item.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{item.message && item.message !== '连接成功' && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text
|
||||
type={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'secondary' : 'danger'}
|
||||
style={{ fontSize: '13px' }}
|
||||
>
|
||||
{item.message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.responseTime !== undefined && item.responseTime !== null && (
|
||||
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
延迟: <Text strong style={{ color: '#1890ff' }}>{item.responseTime}ms</Text>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{apiHealthStatus.length === 0 && !checkingApiHealth && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
||||
<Text type="secondary">暂无 API 状态信息</Text>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card title="代理设置" style={{ marginBottom: '16px' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item
|
||||
label="启用代理"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理主机"
|
||||
name="host"
|
||||
rules={[
|
||||
{ required: true, message: '请输入代理主机地址' },
|
||||
{ pattern: /^[\w\.-]+$/, message: '请输入有效的主机地址' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:127.0.0.1 或 proxy.example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理端口"
|
||||
name="port"
|
||||
rules={[
|
||||
{ required: true, message: '请输入代理端口' },
|
||||
{ type: 'number', min: 1, max: 65535, message: '端口必须在 1-65535 之间' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如:8888"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理用户名(可选)"
|
||||
name="username"
|
||||
>
|
||||
<Input placeholder="如果代理需要认证,请输入用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理密码(可选)"
|
||||
name="password"
|
||||
help={currentConfig ? "留空则不更新密码,输入新密码则更新" : "如果代理需要认证,请输入密码"}
|
||||
>
|
||||
<Input.Password placeholder={currentConfig ? "留空则不更新密码" : "如果代理需要认证,请输入密码"} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleCheck}
|
||||
loading={checking}
|
||||
>
|
||||
检查代理
|
||||
</Button>
|
||||
{checkResult && (
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchConfig}
|
||||
>
|
||||
刷新配置
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{checkResult && (
|
||||
<Alert
|
||||
type={checkResult.success ? 'success' : 'error'}
|
||||
message={checkResult.success ? '代理检查成功' : '代理检查失败'}
|
||||
description={
|
||||
<div>
|
||||
<Text>{checkResult.message}</Text>
|
||||
{(checkResult.responseTime !== undefined || checkResult.latency !== undefined) && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">
|
||||
延迟: {(checkResult.latency ?? checkResult.responseTime) ?? 0}ms
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={{ marginTop: '16px' }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemSettings
|
||||
|
||||
@@ -387,6 +387,65 @@ export const apiService = {
|
||||
*/
|
||||
list: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/orders/tracking', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 代理配置 API
|
||||
*/
|
||||
proxyConfig: {
|
||||
/**
|
||||
* 获取当前代理配置
|
||||
*/
|
||||
get: () =>
|
||||
apiClient.post<ApiResponse<any>>('/proxy-config/get', {}),
|
||||
|
||||
/**
|
||||
* 获取所有代理配置
|
||||
*/
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<any[]>>('/proxy-config/list', {}),
|
||||
|
||||
/**
|
||||
* 保存 HTTP 代理配置
|
||||
*/
|
||||
saveHttp: (data: {
|
||||
enabled: boolean
|
||||
host: string
|
||||
port: number
|
||||
username?: string
|
||||
password?: string
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<any>>('/proxy-config/http/save', data),
|
||||
|
||||
/**
|
||||
* 检查代理是否可用
|
||||
*/
|
||||
check: () =>
|
||||
apiClient.post<ApiResponse<{
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>>('/proxy-config/check', {}),
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
*/
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/proxy-config/delete', data),
|
||||
|
||||
/**
|
||||
* 检查所有 API 的健康状态
|
||||
*/
|
||||
checkApiHealth: () =>
|
||||
apiClient.post<ApiResponse<{
|
||||
apis: Array<{
|
||||
name: string
|
||||
url: string
|
||||
status: string
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>
|
||||
}>>('/proxy-config/api-health-check', {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user