feat: 添加系统管理功能(代理配置和API健康检查)

- 新增代理配置管理功能,支持HTTP代理配置(host、port、用户名、密码)
- 代理配置存储在数据库中,支持实时生效,无需重启服务
- 代理配置变更时自动重连WebSocket连接(订单推送和跟单服务)
- 新增API健康状态检查功能,监控Polymarket各API和Polygon RPC的可用性
- 优化API健康状态UI,支持响应式设计(移动端和桌面端)
- 将Ethereum RPC相关配置和代码统一改为Polygon RPC
- 修复代理检查时的SSL证书验证问题
- 优化代理配置:关闭代理时保留配置信息,避免重新输入
This commit is contained in:
WrBug
2025-12-03 01:44:35 +08:00
parent dd553ae160
commit 4679080d55
33 changed files with 1898 additions and 290 deletions
@@ -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
// 不设置 responseTimeWebSocket 不显示延时
)
} else {
// 所有连接都断开
ApiHealthCheckDto(
name = "Polymarket WebSocket",
url = url,
status = "error",
message = "所有连接断开 ($connectedConnections/$totalConnections)"
// 不设置 responseTimeWebSocket 不显示延时
)
}
} 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(
@@ -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
}
@@ -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 的交易IDtrade.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登录鉴权)';
@@ -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 的交易IDtrade.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='失败交易记录表';
+2
View File
@@ -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 />} />
+7 -1
View File
@@ -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 />,
+397
View File
@@ -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
+59
View File
@@ -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', {})
}
}