Initial commit: Polymarket copy trading bot
- Backend: Spring Boot + Kotlin implementation - Account management with private key import - Leader management - Copy trading configuration - Order synchronization - Balance and position queries via Polymarket API - Ethereum RPC integration for USDC balance - Proxy address calculation - Frontend: React + TypeScript - Account management UI - Mobile responsive design - Account import with private key/mnemonic support - Balance display and account details modal - Database: MySQL with Flyway migrations - API Integration: Polymarket CLOB API, Data API, Ethereum RPC
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package com.wrbug.polymarketbot
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
class PolymarketBotApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PolymarketBotApplication>(*args)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Ethereum RPC API 接口定义
|
||||
* 用于调用 Ethereum JSON-RPC 接口
|
||||
*/
|
||||
interface EthereumRpcApi {
|
||||
|
||||
/**
|
||||
* 调用 Ethereum JSON-RPC 方法
|
||||
*/
|
||||
@POST("/")
|
||||
suspend fun call(@Body request: JsonRpcRequest): Response<JsonRpcResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 请求
|
||||
*/
|
||||
data class JsonRpcRequest(
|
||||
val jsonrpc: String = "2.0",
|
||||
val method: String,
|
||||
val params: List<Any>,
|
||||
val id: Int = 1
|
||||
)
|
||||
|
||||
/**
|
||||
* JSON-RPC 响应
|
||||
*/
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String? = null,
|
||||
val result: String? = null,
|
||||
val error: JsonRpcError? = null,
|
||||
val id: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* JSON-RPC 错误
|
||||
*/
|
||||
data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: Any? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
/**
|
||||
* Polymarket CLOB API 接口定义
|
||||
* 用于程序化地管理市场订单
|
||||
*/
|
||||
interface PolymarketClobApi {
|
||||
|
||||
/**
|
||||
* 获取订单簿
|
||||
*/
|
||||
@GET("/book")
|
||||
suspend fun getOrderbook(
|
||||
@Query("market") market: String
|
||||
): Response<OrderbookResponse>
|
||||
|
||||
/**
|
||||
* 获取价格信息
|
||||
*/
|
||||
@GET("/price")
|
||||
suspend fun getPrice(
|
||||
@Query("market") market: String
|
||||
): Response<PriceResponse>
|
||||
|
||||
/**
|
||||
* 获取中间价
|
||||
*/
|
||||
@GET("/midpoint")
|
||||
suspend fun getMidpoint(
|
||||
@Query("market") market: String
|
||||
): Response<MidpointResponse>
|
||||
|
||||
/**
|
||||
* 获取价差
|
||||
*/
|
||||
@GET("/spreads")
|
||||
suspend fun getSpreads(
|
||||
@Query("market") market: String
|
||||
): Response<SpreadsResponse>
|
||||
|
||||
/**
|
||||
* 创建单个订单
|
||||
*/
|
||||
@POST("/orders")
|
||||
suspend fun createOrder(
|
||||
@Body request: CreateOrderRequest
|
||||
): Response<OrderResponse>
|
||||
|
||||
/**
|
||||
* 批量创建订单
|
||||
*/
|
||||
@POST("/orders/batch")
|
||||
suspend fun createOrdersBatch(
|
||||
@Body request: CreateOrdersBatchRequest
|
||||
): Response<List<OrderResponse>>
|
||||
|
||||
/**
|
||||
* 获取订单信息
|
||||
*/
|
||||
@GET("/orders/{orderId}")
|
||||
suspend fun getOrder(
|
||||
@Path("orderId") orderId: String
|
||||
): Response<OrderResponse>
|
||||
|
||||
/**
|
||||
* 获取活跃订单
|
||||
* 端点: /data/orders
|
||||
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
|
||||
* 虽然项目规范要求使用 POST,但这是外部 API,必须遵循 API 的实际要求
|
||||
*/
|
||||
@GET("/data/orders")
|
||||
suspend fun getActiveOrders(
|
||||
@Query("id") id: String? = null,
|
||||
@Query("market") market: String? = null,
|
||||
@Query("asset_id") asset_id: String? = null,
|
||||
@Query("next_cursor") next_cursor: String? = null
|
||||
): Response<GetActiveOrdersResponse>
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
@DELETE("/orders/{orderId}")
|
||||
suspend fun cancelOrder(
|
||||
@Path("orderId") orderId: String
|
||||
): Response<CancelOrderResponse>
|
||||
|
||||
/**
|
||||
* 批量取消订单
|
||||
*/
|
||||
@DELETE("/orders/batch")
|
||||
suspend fun cancelOrdersBatch(
|
||||
@Body request: CancelOrdersBatchRequest
|
||||
): Response<CancelOrdersBatchResponse>
|
||||
|
||||
/**
|
||||
* 获取交易记录
|
||||
* 端点: /data/trades
|
||||
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
|
||||
*/
|
||||
@GET("/data/trades")
|
||||
suspend fun getTrades(
|
||||
@Query("id") id: String? = null,
|
||||
@Query("maker_address") maker_address: String? = null,
|
||||
@Query("market") market: String? = null,
|
||||
@Query("asset_id") asset_id: String? = null,
|
||||
@Query("before") before: String? = null,
|
||||
@Query("after") after: String? = null,
|
||||
@Query("next_cursor") next_cursor: String? = null
|
||||
): Response<GetTradesResponse>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
data class CreateOrderRequest(
|
||||
val market: String,
|
||||
val side: String, // "BUY" or "SELL"
|
||||
val price: String,
|
||||
val size: String,
|
||||
val type: String = "LIMIT",
|
||||
val expiration: Long? = null
|
||||
)
|
||||
|
||||
data class CreateOrdersBatchRequest(
|
||||
val orders: List<CreateOrderRequest>
|
||||
)
|
||||
|
||||
data class CancelOrdersBatchRequest(
|
||||
val orderIds: List<String>
|
||||
)
|
||||
|
||||
data class OrderbookResponse(
|
||||
val bids: List<OrderbookEntry>,
|
||||
val asks: List<OrderbookEntry>
|
||||
)
|
||||
|
||||
data class OrderbookEntry(
|
||||
val price: String,
|
||||
val size: String
|
||||
)
|
||||
|
||||
data class PriceResponse(
|
||||
val market: String,
|
||||
val lastPrice: String?,
|
||||
val bestBid: String?,
|
||||
val bestAsk: String?
|
||||
)
|
||||
|
||||
data class MidpointResponse(
|
||||
val market: String,
|
||||
val midpoint: String
|
||||
)
|
||||
|
||||
data class SpreadsResponse(
|
||||
val market: String,
|
||||
val spread: String
|
||||
)
|
||||
|
||||
data class OrderResponse(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val filled: String,
|
||||
val status: String,
|
||||
val createdAt: String // ISO 8601 格式字符串
|
||||
)
|
||||
|
||||
data class CancelOrderResponse(
|
||||
val orderId: String,
|
||||
val status: String
|
||||
)
|
||||
|
||||
data class CancelOrdersBatchResponse(
|
||||
val cancelled: List<String>,
|
||||
val failed: List<String>
|
||||
)
|
||||
|
||||
data class TradeResponse(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val timestamp: String, // ISO 8601 格式字符串
|
||||
val user: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取活跃订单响应
|
||||
* 注意:参数通过 @Query 传递,不需要单独的 Request 类
|
||||
*/
|
||||
data class GetActiveOrdersResponse(
|
||||
val data: List<OrderResponse>,
|
||||
val next_cursor: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取交易记录响应
|
||||
*/
|
||||
data class GetTradesResponse(
|
||||
val data: List<TradeResponse>,
|
||||
val next_cursor: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Polymarket Data API 接口定义
|
||||
* 用于查询仓位信息
|
||||
* Base URL: https://data-api.polymarket.com
|
||||
*/
|
||||
interface PolymarketDataApi {
|
||||
|
||||
/**
|
||||
* 获取用户当前仓位
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
|
||||
*/
|
||||
@GET("/positions")
|
||||
suspend fun getPositions(
|
||||
@Query("user") user: String,
|
||||
@Query("market") market: String? = null,
|
||||
@Query("eventId") eventId: String? = null,
|
||||
@Query("sizeThreshold") sizeThreshold: Double? = null,
|
||||
@Query("redeemable") redeemable: Boolean? = null,
|
||||
@Query("mergeable") mergeable: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
@Query("sortBy") sortBy: String? = null,
|
||||
@Query("sortDirection") sortDirection: String? = null,
|
||||
@Query("title") title: String? = null
|
||||
): Response<List<PositionResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓位响应(根据 Polymarket Data API 文档)
|
||||
*/
|
||||
data class PositionResponse(
|
||||
val proxyWallet: String,
|
||||
val asset: String? = null,
|
||||
val conditionId: String? = null,
|
||||
val size: Double? = null,
|
||||
val avgPrice: Double? = null,
|
||||
val initialValue: Double? = null,
|
||||
val currentValue: Double? = null,
|
||||
val cashPnl: Double? = null,
|
||||
val percentPnl: Double? = null,
|
||||
val totalBought: Double? = null,
|
||||
val realizedPnl: Double? = null,
|
||||
val percentRealizedPnl: Double? = null,
|
||||
val curPrice: Double? = null,
|
||||
val redeemable: Boolean? = null,
|
||||
val mergeable: Boolean? = null,
|
||||
val title: String? = null,
|
||||
val slug: String? = null,
|
||||
val icon: String? = null,
|
||||
val eventSlug: String? = null,
|
||||
val outcome: String? = null,
|
||||
val outcomeIndex: Int? = null,
|
||||
val oppositeOutcome: String? = null,
|
||||
val oppositeAsset: String? = null,
|
||||
val endDate: String? = null,
|
||||
val negativeRisk: Boolean? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
/**
|
||||
* Retrofit 配置类
|
||||
* 用于创建 Polymarket CLOB API 客户端(跟单系统需要)
|
||||
*
|
||||
* 注意:
|
||||
* - 查询类接口(如 /book, /price, /trades)不需要认证
|
||||
* - 操作类接口(如 /orders)需要认证,应使用账户级别的 API Key
|
||||
* - 账户 API Key 在调用时动态设置,不在此处配置
|
||||
*/
|
||||
@Configuration
|
||||
class RetrofitConfig {
|
||||
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private lateinit var clobBaseUrl: String
|
||||
|
||||
/**
|
||||
* 创建 CLOB API 客户端
|
||||
* 用于跟单系统的订单操作和交易查询
|
||||
*
|
||||
* 注意:此客户端不包含全局认证拦截器
|
||||
* 需要认证的请求应在调用时使用账户级别的 API Key 动态设置认证头
|
||||
*/
|
||||
@Bean
|
||||
fun polymarketClobApi(): PolymarketClobApi {
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
||||
|
||||
/**
|
||||
* WebSocket 配置类
|
||||
* 用于配置 WebSocket 端点
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
class WebSocketConfig(
|
||||
private val polymarketWebSocketHandler: PolymarketWebSocketHandler
|
||||
) : WebSocketConfigurer {
|
||||
|
||||
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
||||
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
|
||||
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* 账户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/accounts")
|
||||
class AccountController(
|
||||
private val accountService: AccountService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
fun importAccount(@RequestBody request: AccountImportRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
// 参数验证
|
||||
if (request.privateKey.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("私钥不能为空"))
|
||||
}
|
||||
if (request.walletAddress.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("钱包地址不能为空"))
|
||||
}
|
||||
|
||||
val result = accountService.importAccount(request)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功导入账户: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("导入账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("导入账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
fun updateAccount(@RequestBody request: AccountUpdateRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
val result = accountService.updateAccount(request)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功更新账户: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
fun deleteAccount(@RequestBody request: AccountDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = accountService.deleteAccount(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功删除账户: ${request.accountId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getAccountList(): ResponseEntity<ApiResponse<AccountListResponse>> {
|
||||
return try {
|
||||
val result = accountService.getAccountList()
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功查询账户列表: ${response.total} 个账户")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询账户列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
@PostMapping("/detail")
|
||||
fun getAccountDetail(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
val result = accountService.getAccountDetail(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功查询账户详情: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询账户详情失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户详情异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
*/
|
||||
@PostMapping("/balance")
|
||||
fun getAccountBalance(@RequestBody request: AccountBalanceRequest): ResponseEntity<ApiResponse<AccountBalanceResponse>> {
|
||||
return try {
|
||||
val result = accountService.getAccountBalance(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
logger.info("成功查询账户余额")
|
||||
ResponseEntity.ok(ApiResponse.success(balance))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询账户余额失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认账户
|
||||
*/
|
||||
@PostMapping("/set-default")
|
||||
fun setDefaultAccount(@RequestBody request: SetDefaultAccountRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = accountService.setDefaultAccount(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功设置默认账户: ${request.accountId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("设置默认账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("设置默认账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 账户导入请求
|
||||
*/
|
||||
data class AccountImportRequest(
|
||||
val privateKey: String, // 私钥(前端加密后传输)
|
||||
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
||||
val accountName: String? = null,
|
||||
val apiKey: String? = null, // Polymarket API Key(可选)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选)
|
||||
val isDefault: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
data class AccountUpdateRequest(
|
||||
val accountId: Long,
|
||||
val accountName: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val apiSecret: String? = null,
|
||||
val apiPassphrase: String? = null,
|
||||
val isDefault: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户删除请求
|
||||
*/
|
||||
data class AccountDeleteRequest(
|
||||
val accountId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户详情请求
|
||||
*/
|
||||
data class AccountDetailRequest(
|
||||
val accountId: Long? = null // 不提供则返回默认账户
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额请求
|
||||
*/
|
||||
data class AccountBalanceRequest(
|
||||
val accountId: Long? = null // 不提供则查询默认账户
|
||||
)
|
||||
|
||||
/**
|
||||
* 设置默认账户请求
|
||||
*/
|
||||
data class SetDefaultAccountRequest(
|
||||
val accountId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户信息响应
|
||||
*/
|
||||
data class AccountDto(
|
||||
val id: Long,
|
||||
val walletAddress: String,
|
||||
val accountName: String?,
|
||||
val isDefault: Boolean,
|
||||
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
|
||||
val apiSecretConfigured: Boolean, // API Secret 是否已配置
|
||||
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
||||
val balance: String? = null, // 账户余额(可选)
|
||||
val totalOrders: Long? = null, // 总订单数(可选)
|
||||
val totalPnl: String? = null // 总盈亏(可选)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户列表响应
|
||||
*/
|
||||
data class AccountListResponse(
|
||||
val list: List<AccountDto>,
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额响应
|
||||
*/
|
||||
data class AccountBalanceResponse(
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 持仓信息
|
||||
*/
|
||||
data class PositionDto(
|
||||
val marketId: String,
|
||||
val side: String, // YES 或 NO
|
||||
val quantity: String,
|
||||
val avgPrice: String,
|
||||
val currentValue: String,
|
||||
val pnl: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 统一API响应格式
|
||||
* @param code 响应码,0表示成功,非0表示失败
|
||||
* @param data 响应数据,可以是任意类型(对象、数组、字符串、数字等)
|
||||
* @param msg 响应消息,成功时通常为空,失败时包含错误提示
|
||||
*/
|
||||
data class ApiResponse<T>(
|
||||
val code: Int,
|
||||
val data: T?,
|
||||
val msg: String
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* 创建成功响应
|
||||
*/
|
||||
fun <T> success(data: T?): ApiResponse<T> {
|
||||
return ApiResponse(code = 0, data = data, msg = "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应
|
||||
*/
|
||||
fun <T> error(code: Int, msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = code, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建参数错误响应
|
||||
*/
|
||||
fun <T> paramError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 1001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建认证错误响应
|
||||
*/
|
||||
fun <T> authError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 2001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资源不存在响应
|
||||
*/
|
||||
fun <T> notFound(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 3001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建业务逻辑错误响应
|
||||
*/
|
||||
fun <T> businessError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 4001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务器内部错误响应
|
||||
*/
|
||||
fun <T> serverError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 5001, data = null, msg = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 市场 DTO(用于返回给前端)
|
||||
*/
|
||||
data class MarketDto(
|
||||
val id: String,
|
||||
val question: String,
|
||||
val slug: String,
|
||||
val category: String,
|
||||
val active: Boolean,
|
||||
val volume: String?,
|
||||
val liquidity: String?,
|
||||
val endDate: Long?, // 时间戳(毫秒)
|
||||
val createdAt: Long?, // 时间戳(毫秒)
|
||||
val updatedAt: Long?, // 时间戳(毫秒)
|
||||
val outcomes: List<OutcomeDto>?,
|
||||
val conditionId: String?,
|
||||
val description: String?,
|
||||
val image: String?,
|
||||
val icon: String?,
|
||||
val closed: Boolean?,
|
||||
val archived: Boolean?,
|
||||
val volumeNum: Double?,
|
||||
val liquidityNum: Double?,
|
||||
val bestBid: Double?,
|
||||
val bestAsk: Double?,
|
||||
val lastTradePrice: Double?
|
||||
)
|
||||
|
||||
/**
|
||||
* 结果 DTO
|
||||
*/
|
||||
data class OutcomeDto(
|
||||
val name: String, // 结果名称,如 "Yes" 或 "No"
|
||||
val price: String // 价格
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件 DTO
|
||||
*/
|
||||
data class EventDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val active: Boolean,
|
||||
val markets: List<MarketDto>?,
|
||||
val createdAt: Long? // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 系列 DTO
|
||||
*/
|
||||
data class SeriesDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val events: List<EventDto>?,
|
||||
val createdAt: Long? // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 评论 DTO
|
||||
*/
|
||||
data class CommentDto(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val content: String,
|
||||
val parent: String?,
|
||||
val createdAt: Long, // 时间戳(毫秒)
|
||||
val user: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 订单 DTO(用于返回给前端)
|
||||
*/
|
||||
data class OrderDto(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val filled: String,
|
||||
val status: String,
|
||||
val createdAt: Long // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 交易 DTO
|
||||
*/
|
||||
data class TradeDto(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val timestamp: Long, // 时间戳(毫秒)
|
||||
val user: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 账户信息实体
|
||||
* 用于存储跟单者的账户信息(支持多账户)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "copy_trading_accounts")
|
||||
data class Account(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(加密存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
|
||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,加密存储)
|
||||
|
||||
@Column(name = "api_secret", length = 500)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选,加密存储)
|
||||
|
||||
@Column(name = "api_passphrase", length = 500)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,加密存储)
|
||||
|
||||
@Column(name = "account_name", length = 100)
|
||||
val accountName: String? = null,
|
||||
|
||||
@Column(name = "is_default", nullable = false)
|
||||
val isDefault: Boolean = false, // 是否默认账户
|
||||
|
||||
@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,33 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 账户 Repository
|
||||
*/
|
||||
@Repository
|
||||
interface AccountRepository : JpaRepository<Account, Long> {
|
||||
|
||||
/**
|
||||
* 根据钱包地址查找账户
|
||||
*/
|
||||
fun findByWalletAddress(walletAddress: String): Account?
|
||||
|
||||
/**
|
||||
* 查找默认账户
|
||||
*/
|
||||
fun findByIsDefaultTrue(): Account?
|
||||
|
||||
/**
|
||||
* 查找所有账户,按创建时间排序
|
||||
*/
|
||||
fun findAllByOrderByCreatedAtAsc(): List<Account>
|
||||
|
||||
/**
|
||||
* 检查钱包地址是否存在
|
||||
*/
|
||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 账户管理服务
|
||||
*/
|
||||
@Service
|
||||
class AccountService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountService::class.java)
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@Transactional
|
||||
fun importAccount(request: AccountImportRequest): Result<AccountDto> {
|
||||
return try {
|
||||
// 1. 验证钱包地址格式
|
||||
if (!isValidWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 检查地址是否已存在
|
||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
||||
}
|
||||
|
||||
// 3. 验证私钥和地址的对应关系
|
||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||
if (!isValidPrivateKey(request.privateKey)) {
|
||||
return Result.failure(IllegalArgumentException("无效的私钥格式"))
|
||||
}
|
||||
|
||||
// 4. 加密私钥和 API 凭证
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiSecret = request.apiSecret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = request.apiPassphrase?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 5. 如果设置为默认账户,取消其他账户的默认状态
|
||||
if (request.isDefault) {
|
||||
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
|
||||
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 获取代理地址(必须成功,否则导入失败)
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
logger.info("成功获取代理地址: ${request.walletAddress} -> $address")
|
||||
address
|
||||
} else {
|
||||
logger.error("获取代理地址返回空值")
|
||||
throw IllegalStateException("获取代理地址失败:返回值为空")
|
||||
}
|
||||
} else {
|
||||
val error = proxyResult.exceptionOrNull()
|
||||
logger.error("获取代理地址失败: ${error?.message}")
|
||||
throw IllegalStateException("获取代理地址失败: ${error?.message}。请确保已配置 Ethereum RPC URL 且 RPC 节点可用")
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 创建账户
|
||||
val account = Account(
|
||||
privateKey = encryptedPrivateKey,
|
||||
walletAddress = request.walletAddress,
|
||||
proxyAddress = proxyAddress,
|
||||
apiKey = encryptedApiKey,
|
||||
apiSecret = encryptedApiSecret,
|
||||
apiPassphrase = encryptedApiPassphrase,
|
||||
accountName = request.accountName,
|
||||
isDefault = request.isDefault,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val saved = accountRepository.save(account)
|
||||
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("导入账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@Transactional
|
||||
fun updateAccount(request: AccountUpdateRequest): Result<AccountDto> {
|
||||
return try {
|
||||
val account = accountRepository.findById(request.accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 更新账户名称
|
||||
val updatedAccountName = request.accountName ?: account.accountName
|
||||
|
||||
// 更新 API 凭证
|
||||
val updatedApiKey = if (request.apiKey != null) {
|
||||
cryptoUtils.encrypt(request.apiKey)
|
||||
} else {
|
||||
account.apiKey
|
||||
}
|
||||
val updatedApiSecret = if (request.apiSecret != null) {
|
||||
cryptoUtils.encrypt(request.apiSecret)
|
||||
} else {
|
||||
account.apiSecret
|
||||
}
|
||||
val updatedApiPassphrase = if (request.apiPassphrase != null) {
|
||||
cryptoUtils.encrypt(request.apiPassphrase)
|
||||
} else {
|
||||
account.apiPassphrase
|
||||
}
|
||||
|
||||
// 如果设置为默认账户,取消其他账户的默认状态
|
||||
val updatedIsDefault = request.isDefault ?: account.isDefault
|
||||
if (updatedIsDefault && !account.isDefault) {
|
||||
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
|
||||
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
}
|
||||
}
|
||||
|
||||
val updated = account.copy(
|
||||
accountName = updatedAccountName,
|
||||
apiKey = updatedApiKey,
|
||||
apiSecret = updatedApiSecret,
|
||||
apiPassphrase = updatedApiPassphrase,
|
||||
isDefault = updatedIsDefault,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val saved = accountRepository.save(updated)
|
||||
logger.info("成功更新账户: ${saved.id}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteAccount(accountId: Long): Result<Unit> {
|
||||
return try {
|
||||
val account = accountRepository.findById(accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 注意:不再检查活跃订单,允许用户删除有活跃订单的账户
|
||||
// 前端会显示确认提示框,由用户决定是否删除
|
||||
|
||||
// 如果删除的是默认账户,需要先设置其他账户为默认
|
||||
if (account.isDefault) {
|
||||
val otherAccounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
.filter { it.id != accountId }
|
||||
|
||||
if (otherAccounts.isNotEmpty()) {
|
||||
val newDefault = otherAccounts.first().copy(
|
||||
isDefault = true,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
accountRepository.save(newDefault)
|
||||
} else {
|
||||
return Result.failure(IllegalStateException("不能删除最后一个账户"))
|
||||
}
|
||||
}
|
||||
|
||||
accountRepository.delete(account)
|
||||
logger.info("成功删除账户: $accountId")
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
*/
|
||||
fun getAccountList(): Result<AccountListResponse> {
|
||||
return try {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
val accountDtos = accounts.map { toDto(it) }
|
||||
|
||||
Result.success(AccountListResponse(
|
||||
list = accountDtos,
|
||||
total = accountDtos.size.toLong()
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户列表失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
fun getAccountDetail(accountId: Long?): Result<AccountDto> {
|
||||
return try {
|
||||
val account = if (accountId != null) {
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
} else {
|
||||
accountRepository.findByIsDefaultTrue()
|
||||
}
|
||||
|
||||
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
Result.success(toDto(account))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户详情失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
* 通过链上 RPC 查询 USDC 余额,并通过 Subgraph API 查询持仓信息
|
||||
*/
|
||||
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
|
||||
return try {
|
||||
val account = if (accountId != null) {
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
} else {
|
||||
accountRepository.findByIsDefaultTrue()
|
||||
}
|
||||
|
||||
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 检查代理地址是否存在
|
||||
if (account.proxyAddress.isBlank()) {
|
||||
logger.error("账户 ${account.id} 的代理地址为空,无法查询余额")
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||
}
|
||||
|
||||
// 查询 USDC 余额和持仓信息
|
||||
val balanceResult = runBlocking {
|
||||
try {
|
||||
// 先查询持仓信息(用于计算仓位余额和返回持仓列表)
|
||||
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
positionsResult.getOrNull()?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 计算仓位余额(持仓总价值)
|
||||
val positionBalance = positions.sumOf {
|
||||
it.currentValue.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
// 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
// 必须使用代理地址查询
|
||||
val availableBalanceResult = blockchainService.getUsdcBalance(
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance)
|
||||
|
||||
AccountBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance.toPlainString(),
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询余额失败: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(balanceResult)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认账户
|
||||
*/
|
||||
@Transactional
|
||||
fun setDefaultAccount(accountId: Long): Result<Unit> {
|
||||
return try {
|
||||
val account = accountRepository.findById(accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 取消其他账户的默认状态
|
||||
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
|
||||
if (defaultAccount.id != account.id) {
|
||||
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前账户为默认
|
||||
val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
|
||||
logger.info("成功设置默认账户: $accountId")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("设置默认账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
* 包含交易统计数据(总订单数和总盈亏)
|
||||
*/
|
||||
private fun toDto(account: Account): AccountDto {
|
||||
return runBlocking {
|
||||
val statistics = getAccountStatistics(account)
|
||||
AccountDto(
|
||||
id = account.id!!,
|
||||
walletAddress = account.walletAddress,
|
||||
accountName = account.accountName,
|
||||
isDefault = account.isDefault,
|
||||
apiKeyConfigured = account.apiKey != null,
|
||||
apiSecretConfigured = account.apiSecret != null,
|
||||
apiPassphraseConfigured = account.apiPassphrase != null,
|
||||
totalOrders = statistics.totalOrders,
|
||||
totalPnl = statistics.totalPnl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户交易统计数据
|
||||
*/
|
||||
private suspend fun getAccountStatistics(account: Account): AccountStatistics {
|
||||
return try {
|
||||
// 如果账户没有配置 API 凭证,无法查询统计数据
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
return AccountStatistics(totalOrders = null, totalPnl = null)
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiKey = cryptoUtils.decrypt(account.apiKey)
|
||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
|
||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
|
||||
|
||||
// 创建带认证的 API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
||||
|
||||
// 1. 查询交易记录数量(总订单数)
|
||||
val tradesResult = runBlocking {
|
||||
try {
|
||||
// 使用代理地址查询交易记录
|
||||
val response = clobApi.getTrades(
|
||||
maker_address = account.proxyAddress,
|
||||
next_cursor = null
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val tradesResponse = response.body()!!
|
||||
// 统计所有交易(需要分页查询所有)
|
||||
var totalTrades = tradesResponse.data.size
|
||||
var nextCursor = tradesResponse.next_cursor
|
||||
|
||||
// 分页查询所有交易
|
||||
while (nextCursor != null && nextCursor.isNotEmpty()) {
|
||||
val nextResponse = clobApi.getTrades(
|
||||
maker_address = account.proxyAddress,
|
||||
next_cursor = nextCursor
|
||||
)
|
||||
if (nextResponse.isSuccessful && nextResponse.body() != null) {
|
||||
val nextTradesResponse = nextResponse.body()!!
|
||||
totalTrades += nextTradesResponse.data.size
|
||||
nextCursor = nextTradesResponse.next_cursor
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
Result.success(totalTrades.toLong())
|
||||
} else {
|
||||
Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询交易记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询仓位信息计算总盈亏(已实现盈亏)
|
||||
val totalPnlResult = runBlocking {
|
||||
try {
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
if (positionsResult.isSuccess) {
|
||||
val positions = positionsResult.getOrNull() ?: emptyList()
|
||||
// 汇总所有仓位的已实现盈亏
|
||||
val totalRealizedPnl = positions.sumOf { pos ->
|
||||
pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
}
|
||||
Result.success(totalRealizedPnl.toPlainString())
|
||||
} else {
|
||||
Result.failure(Exception("查询仓位信息失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询仓位盈亏失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
AccountStatistics(
|
||||
totalOrders = tradesResult.getOrNull(),
|
||||
totalPnl = totalPnlResult.getOrNull()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取账户统计数据失败: ${e.message}", e)
|
||||
AccountStatistics(totalOrders = null, totalPnl = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户统计数据
|
||||
*/
|
||||
private data class AccountStatistics(
|
||||
val totalOrders: Long?,
|
||||
val totalPnl: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证钱包地址格式
|
||||
*/
|
||||
private fun isValidWalletAddress(address: String): Boolean {
|
||||
// 以太坊地址格式:0x 开头,42 位字符
|
||||
return address.startsWith("0x") && address.length == 42 && address.matches(Regex("^0x[0-9a-fA-F]{40}$"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证私钥格式
|
||||
*/
|
||||
private fun isValidPrivateKey(privateKey: String): Boolean {
|
||||
// 私钥格式:64 位十六进制字符(可选 0x 前缀)
|
||||
val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey
|
||||
return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否有活跃订单
|
||||
* 使用账户的 API Key 查询该账户的活跃订单
|
||||
*/
|
||||
private suspend fun hasActiveOrders(account: Account): Boolean {
|
||||
return try {
|
||||
// 如果账户没有配置 API 凭证,无法查询活跃订单,允许删除
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户 ${account.id} 未配置 API 凭证,无法查询活跃订单,允许删除")
|
||||
return false
|
||||
}
|
||||
|
||||
// 解密 API 凭证(前面已检查不为 null)
|
||||
val apiKey = cryptoUtils.decrypt(account.apiKey)
|
||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
|
||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
|
||||
|
||||
// 创建带认证的 API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
||||
|
||||
// 查询活跃订单(只查询第一条,用于判断是否有订单)
|
||||
// 使用 next_cursor 参数进行分页,这里只查询第一页
|
||||
val response = clobApi.getActiveOrders(
|
||||
id = null,
|
||||
market = null,
|
||||
asset_id = null,
|
||||
next_cursor = null // null 表示从第一页开始
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val ordersResponse = response.body()!!
|
||||
val hasOrders = ordersResponse.data.isNotEmpty()
|
||||
logger.debug("账户 ${account.id} 活跃订单检查结果: $hasOrders (订单数: ${ordersResponse.data.size})")
|
||||
hasOrders
|
||||
} else {
|
||||
// 如果查询失败(可能是认证失败或网络问题),记录警告但允许删除
|
||||
// 因为无法确定是否有活跃订单,不应该阻止删除操作
|
||||
logger.warn("查询活跃订单失败: ${response.code()} ${response.message()},允许删除账户")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果查询异常(网络问题、API 错误等),记录警告但允许删除
|
||||
// 因为无法确定是否有活跃订单,不应该阻止删除操作
|
||||
logger.warn("检查活跃订单异常: ${e.message},允许删除账户", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 区块链查询服务
|
||||
* 用于查询链上余额和持仓信息
|
||||
*/
|
||||
@Service
|
||||
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,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||
|
||||
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// Polymarket 代理工厂合约地址(Polygon 主网)
|
||||
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
|
||||
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
|
||||
// 获取代理地址的函数签名
|
||||
// 根据 Polygonscan 的 F4 方法,函数签名为: computeProxyAddress(address)
|
||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
||||
dataApiBaseUrl.dropLast(1)
|
||||
} else {
|
||||
dataApiBaseUrl
|
||||
}
|
||||
val okHttpClient = createClient()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketDataApi::class.java)
|
||||
}
|
||||
|
||||
private val ethereumRpcApi: EthereumRpcApi? by lazy {
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(ethereumRpcUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Polymarket 代理钱包地址
|
||||
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
|
||||
* @param walletAddress 用户的钱包地址
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
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 环境变量"))
|
||||
}
|
||||
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
|
||||
// 计算函数选择器
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
|
||||
// 编码地址参数
|
||||
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
|
||||
// 构建调用数据
|
||||
val data = functionSelector + encodedAddress
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to proxyFactoryContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 解析代理地址
|
||||
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
|
||||
|
||||
logger.debug("获取代理地址成功: 原始地址=$walletAddress, 代理地址=$proxyAddress")
|
||||
Result.success(proxyAddress)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取代理地址失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户 USDC 余额
|
||||
* 通过 Ethereum RPC 查询 ERC-20 代币余额
|
||||
* @param walletAddress 钱包地址(用于日志记录)
|
||||
* @param proxyAddress 代理地址(必须提供)
|
||||
* 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据)
|
||||
*/
|
||||
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 (proxyAddress.isBlank()) {
|
||||
logger.error("代理地址为空,无法查询余额")
|
||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||
}
|
||||
|
||||
logger.debug("使用代理地址查询余额: $proxyAddress (原始地址: $walletAddress)")
|
||||
|
||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||
Result.success(balance)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 USDC 余额失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
*/
|
||||
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
|
||||
// 构建 ERC-20 balanceOf 函数调用
|
||||
// function signature: balanceOf(address) -> bytes4(0x70a08231)
|
||||
// 参数编码: address (32 bytes, padded)
|
||||
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
val data = functionSelector + paddedAddress
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to usdcContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求(使用 Retrofit)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 将十六进制转换为 BigDecimal(USDC 有 6 位小数)
|
||||
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
|
||||
val balance = BigDecimal(balanceWei).divide(BigDecimal("1000000")) // USDC 有 6 位小数
|
||||
|
||||
return balance.toPlainString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户持仓信息
|
||||
* 通过 Polymarket Data API 查询
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
|
||||
*/
|
||||
suspend fun getPositions(proxyWalletAddress: String): Result<List<PositionResponse>> {
|
||||
return try {
|
||||
// 使用代理钱包地址查询仓位
|
||||
val response = dataApi.getPositions(
|
||||
user = proxyWalletAddress,
|
||||
limit = 500, // 最大限制
|
||||
offset = 0
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val positions = response.body()!!
|
||||
logger.debug("查询到 ${positions.size} 个仓位")
|
||||
Result.success(positions)
|
||||
} else {
|
||||
val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}"
|
||||
logger.error(errorMsg)
|
||||
Result.failure(Exception(errorMsg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询持仓信息失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* Polymarket CLOB API 服务封装
|
||||
* 提供订单操作、市场数据、交易数据等功能
|
||||
*/
|
||||
@Service
|
||||
class PolymarketClobService(
|
||||
private val clobApi: PolymarketClobApi
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java)
|
||||
|
||||
/**
|
||||
* 获取订单簿
|
||||
*/
|
||||
suspend fun getOrderbook(market: String): Result<OrderbookResponse> {
|
||||
return try {
|
||||
val response = clobApi.getOrderbook(market)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("获取订单簿失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取订单簿异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取价格信息
|
||||
*/
|
||||
suspend fun getPrice(market: String): Result<PriceResponse> {
|
||||
return try {
|
||||
val response = clobApi.getPrice(market)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("获取价格失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取价格异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中间价
|
||||
*/
|
||||
suspend fun getMidpoint(market: String): Result<MidpointResponse> {
|
||||
return try {
|
||||
val response = clobApi.getMidpoint(market)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("获取中间价失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取中间价异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
|
||||
return try {
|
||||
val response = clobApi.createOrder(request)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("创建订单失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建订单异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃订单
|
||||
*/
|
||||
suspend fun getActiveOrders(
|
||||
id: String? = null,
|
||||
market: String? = null,
|
||||
asset_id: String? = null,
|
||||
next_cursor: String? = null
|
||||
): Result<List<OrderResponse>> {
|
||||
return try {
|
||||
val response = clobApi.getActiveOrders(
|
||||
id = id,
|
||||
market = market,
|
||||
asset_id = asset_id,
|
||||
next_cursor = next_cursor
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val ordersResponse = response.body()!!
|
||||
Result.success(ordersResponse.data)
|
||||
} else {
|
||||
Result.failure(Exception("获取活跃订单失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取活跃订单异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
suspend fun cancelOrder(orderId: String): Result<CancelOrderResponse> {
|
||||
return try {
|
||||
val response = clobApi.cancelOrder(orderId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("取消订单失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("取消订单异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交易记录
|
||||
*/
|
||||
suspend fun getTrades(
|
||||
id: String? = null,
|
||||
maker_address: String? = null,
|
||||
market: String? = null,
|
||||
asset_id: String? = null,
|
||||
before: String? = null,
|
||||
after: String? = null,
|
||||
next_cursor: String? = null
|
||||
): Result<List<TradeResponse>> {
|
||||
return try {
|
||||
val response = clobApi.getTrades(
|
||||
id = id,
|
||||
maker_address = maker_address,
|
||||
market = market,
|
||||
asset_id = asset_id,
|
||||
before = before,
|
||||
after = after,
|
||||
next_cursor = next_cursor
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val tradesResponse = response.body()!!
|
||||
Result.success(tradesResponse.data)
|
||||
} else {
|
||||
Result.failure(Exception("获取交易记录失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取交易记录异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
/**
|
||||
* 分类验证工具类
|
||||
* 用于验证分类参数是否符合项目要求(仅支持 sports 和 crypto)
|
||||
*/
|
||||
object CategoryValidator {
|
||||
|
||||
/**
|
||||
* 支持的分类列表
|
||||
*/
|
||||
private val SUPPORTED_CATEGORIES = setOf("sports", "crypto")
|
||||
|
||||
/**
|
||||
* 分类名称映射(将 Polymarket API 返回的分类名称映射到标准分类)
|
||||
*/
|
||||
private val CATEGORY_MAPPING = mapOf(
|
||||
"sports" to "sports",
|
||||
"crypto" to "crypto",
|
||||
"cryptocurrency" to "crypto",
|
||||
"cryptocurrencies" to "crypto"
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证分类是否有效(支持精确匹配和关键字匹配)
|
||||
* @param category 分类名称
|
||||
* @return 是否有效
|
||||
*/
|
||||
fun isValid(category: String?): Boolean {
|
||||
if (category == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val categoryLower = category.lowercase()
|
||||
|
||||
// 精确匹配
|
||||
if (categoryLower in SUPPORTED_CATEGORIES) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 映射匹配
|
||||
if (categoryLower in CATEGORY_MAPPING.keys) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 关键字匹配
|
||||
if (categoryLower.contains("sport")) {
|
||||
return true
|
||||
}
|
||||
if (categoryLower.contains("crypto")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化分类名称
|
||||
* @param category 原始分类名称
|
||||
* @return 标准化后的分类名称(sports 或 crypto)
|
||||
*/
|
||||
fun normalizeCategory(category: String?): String? {
|
||||
if (category == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val categoryLower = category.lowercase()
|
||||
|
||||
// 映射匹配
|
||||
CATEGORY_MAPPING[categoryLower]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
// 关键字匹配
|
||||
if (categoryLower.contains("sport")) {
|
||||
return "sports"
|
||||
}
|
||||
if (categoryLower.contains("crypto")) {
|
||||
return "crypto"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分类,如果无效则抛出异常
|
||||
* @param category 分类名称
|
||||
* @throws IllegalArgumentException 如果分类无效
|
||||
*/
|
||||
fun validate(category: String?) {
|
||||
if (!isValid(category)) {
|
||||
throw IllegalArgumentException("不支持的分类: $category,仅支持: ${SUPPORTED_CATEGORIES.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的分类
|
||||
* @return 支持的分类列表
|
||||
*/
|
||||
fun getSupportedCategories(): Set<String> {
|
||||
return SUPPORTED_CATEGORIES
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* 加密工具类
|
||||
* 用于加密存储私钥和 API Key
|
||||
*/
|
||||
@Component
|
||||
class CryptoUtils {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoUtils::class.java)
|
||||
|
||||
@Value("\${crypto.secret.key:}")
|
||||
private var secretKey: String = ""
|
||||
|
||||
private val algorithm = "AES"
|
||||
private val transformation = "AES"
|
||||
|
||||
/**
|
||||
* 获取密钥字节数组
|
||||
* 使用 SHA-256 哈希从任意长度的密钥生成固定 32 字节的密钥(AES-256)
|
||||
*/
|
||||
private fun getKeyBytes(): ByteArray {
|
||||
val rawKey = if (secretKey.isEmpty()) {
|
||||
logger.warn("未配置加密密钥,使用默认密钥(仅用于开发环境)")
|
||||
"default-secret-key-32-bytes-long!!"
|
||||
} else {
|
||||
secretKey
|
||||
}
|
||||
|
||||
// 将原始密钥转换为字节数组
|
||||
val keyBytes = rawKey.toByteArray(StandardCharsets.UTF_8)
|
||||
|
||||
// 使用 SHA-256 哈希生成固定 32 字节的密钥(AES-256)
|
||||
val messageDigest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
return messageDigest.digest(keyBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密字符串
|
||||
*/
|
||||
fun encrypt(plainText: String): String {
|
||||
return try {
|
||||
val keyBytes = getKeyBytes()
|
||||
val key = SecretKeySpec(keyBytes, algorithm)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val encrypted = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8))
|
||||
Base64.getEncoder().encodeToString(encrypted)
|
||||
} catch (e: Exception) {
|
||||
logger.error("加密失败", e)
|
||||
throw RuntimeException("加密失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密字符串
|
||||
*/
|
||||
fun decrypt(encryptedText: String): String {
|
||||
return try {
|
||||
val keyBytes = getKeyBytes()
|
||||
val key = SecretKeySpec(keyBytes, algorithm)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key)
|
||||
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText))
|
||||
String(decrypted, StandardCharsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密失败", e)
|
||||
throw RuntimeException("解密失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/**
|
||||
* 日期工具类
|
||||
* 用于处理日期字符串和时间戳之间的转换
|
||||
*/
|
||||
object DateUtils {
|
||||
|
||||
/**
|
||||
* ISO 8601 日期时间格式化器
|
||||
*/
|
||||
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
/**
|
||||
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
|
||||
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
|
||||
* @return 时间戳(毫秒),如果转换失败返回 null
|
||||
*/
|
||||
fun parseToTimestamp(dateString: String?): Long? {
|
||||
if (dateString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
// 尝试解析 ISO 8601 格式
|
||||
val instant = Instant.parse(dateString)
|
||||
instant.toEpochMilli()
|
||||
} catch (e: DateTimeParseException) {
|
||||
// 如果解析失败,尝试其他格式
|
||||
try {
|
||||
// 尝试使用 ISO_DATE_TIME 格式化器
|
||||
val dateTime = java.time.ZonedDateTime.parse(dateString, isoFormatter)
|
||||
dateTime.toInstant().toEpochMilli()
|
||||
} catch (e2: Exception) {
|
||||
// 所有解析都失败,返回 null
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳(毫秒)转换为 ISO 8601 格式的日期字符串
|
||||
* @param timestamp 时间戳(毫秒)
|
||||
* @return ISO 8601 格式的日期字符串
|
||||
*/
|
||||
fun formatFromTimestamp(timestamp: Long?): String? {
|
||||
if (timestamp == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val instant = Instant.ofEpochMilli(timestamp)
|
||||
instant.toString()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Ethereum 工具类
|
||||
* 用于计算函数签名、编码参数等
|
||||
*/
|
||||
object EthereumUtils {
|
||||
|
||||
/**
|
||||
* 计算函数选择器(前4个字节)
|
||||
* @param functionSignature 函数签名,例如 "computeProxyAddress(address)"
|
||||
* @return 函数选择器,例如 "0x12345678"
|
||||
*/
|
||||
fun getFunctionSelector(functionSignature: String): String {
|
||||
val hash = keccak256(functionSignature.toByteArray())
|
||||
return "0x" + hash.substring(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码地址参数(32字节,左对齐)
|
||||
* @param address 地址,例如 "0x1234..."
|
||||
* @return 编码后的地址,64个十六进制字符
|
||||
*/
|
||||
fun encodeAddress(address: String): String {
|
||||
val cleanAddress = address.removePrefix("0x").lowercase()
|
||||
return cleanAddress.padStart(64, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从合约调用结果中解析地址
|
||||
* @param hexResult 十六进制结果
|
||||
* @return 地址字符串
|
||||
*/
|
||||
fun decodeAddress(hexResult: String): String {
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
// 地址是最后20字节(40个十六进制字符)
|
||||
val addressHex = cleanHex.takeLast(40)
|
||||
return "0x$addressHex"
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Keccak-256 哈希(Ethereum 标准)
|
||||
* 使用 BouncyCastle 库实现真正的 Keccak-256
|
||||
*/
|
||||
private fun keccak256(data: ByteArray): String {
|
||||
val digest = KeccakDigest(256)
|
||||
digest.update(data, 0, data.size)
|
||||
val hash = ByteArray(digest.digestSize)
|
||||
digest.doFinal(hash, 0)
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* JSON 工具类
|
||||
* 用于解析 JSON 字符串
|
||||
*/
|
||||
object JsonUtils {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串数组
|
||||
* @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]"
|
||||
* @return 字符串列表,如果解析失败返回空列表
|
||||
*/
|
||||
fun parseStringArray(jsonString: String?): List<String> {
|
||||
if (jsonString.isNullOrBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return try {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* BigDecimal乘法扩展函数
|
||||
* 安全地将BigDecimal与任意数值类型相乘
|
||||
* @param value 乘数,支持BigDecimal、BigInteger、Number类型或可转换为BigDecimal的字符串
|
||||
* @return 乘法结果,如果转换失败返回BigDecimal.ZERO
|
||||
*/
|
||||
fun BigDecimal.multi(value: Any): BigDecimal {
|
||||
kotlin.runCatching {
|
||||
if (value is BigDecimal) {
|
||||
return multiply(value)
|
||||
}
|
||||
if (value is BigInteger) {
|
||||
return multiply(value.toBigDecimal())
|
||||
}
|
||||
if (value is Number) {
|
||||
return multiply(value.toSafeBigDecimal())
|
||||
}
|
||||
return multiply(BigDecimal(value.toString()))
|
||||
}
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDecimal除法扩展函数
|
||||
* 安全地将BigDecimal与任意数值类型相除
|
||||
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
|
||||
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
|
||||
*/
|
||||
fun BigDecimal.div(value: Any): BigDecimal {
|
||||
kotlin.runCatching {
|
||||
if (value is BigDecimal) {
|
||||
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
if (value is BigInteger) {
|
||||
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return IllegalBigDecimal
|
||||
}
|
||||
|
||||
/**
|
||||
* BigInteger乘法扩展函数
|
||||
* 将BigInteger转换为BigDecimal后与任意数值类型相乘
|
||||
* @param value 乘数,支持任意可转换为BigDecimal的类型
|
||||
* @return 乘法结果,如果转换失败返回IllegalBigDecimal
|
||||
*/
|
||||
fun BigInteger.multi(value: Any): BigDecimal {
|
||||
val v = this.toBigDecimal()
|
||||
return runCatching {
|
||||
v.multi(value)
|
||||
}.getOrDefault(IllegalBigDecimal)
|
||||
}
|
||||
|
||||
/**
|
||||
* 大于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值大于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.gt(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() > target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 大于等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值大于等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.gte(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() >= target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 小于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值小于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.lt(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() < target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 小于等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值小于等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.lte(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() <= target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值是否相等
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.eq(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() == target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 不等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值是否不相等
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值不等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.neq(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() != target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.*
|
||||
|
||||
/**
|
||||
* 创建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)
|
||||
|
||||
/**
|
||||
* 为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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为OkHttpClient创建信任所有证书的SSL工厂
|
||||
* @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 this
|
||||
}
|
||||
|
||||
/**
|
||||
* 信任所有证书的TrustManager
|
||||
*/
|
||||
class TrustAllManager : X509TrustManager {
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate?>?, authType: String?) {
|
||||
}
|
||||
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate?>?, authType: String?) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers() = arrayOfNulls<X509Certificate>(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 信任所有主机名的HostnameVerifier
|
||||
*/
|
||||
class TrustAllHostnameVerifier : HostnameVerifier {
|
||||
override fun verify(hostname: String?, session: SSLSession?): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Polymarket API 认证拦截器
|
||||
* 实现 L2 认证(使用 API Key、Secret、Passphrase)
|
||||
*
|
||||
* 认证方式:
|
||||
* 1. 生成时间戳(秒)
|
||||
* 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名
|
||||
* 3. 在请求头中添加:
|
||||
* - X-API-KEY: API Key
|
||||
* - X-API-SIGN: Base64 编码的签名
|
||||
* - X-API-TIMESTAMP: 时间戳
|
||||
* - X-API-PASSPHRASE: Passphrase
|
||||
*/
|
||||
class PolymarketAuthInterceptor(
|
||||
private val apiKey: String,
|
||||
private val apiSecret: String,
|
||||
private val apiPassphrase: String
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// 生成时间戳(秒)
|
||||
val timestamp = Instant.now().epochSecond.toString()
|
||||
|
||||
// 构建签名字符串: timestamp + method + path + body
|
||||
val method = originalRequest.method
|
||||
val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else ""
|
||||
|
||||
// 读取请求体(如果存在)
|
||||
val body = originalRequest.body?.let { requestBody ->
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
buffer.readUtf8()
|
||||
} ?: ""
|
||||
|
||||
val signString = "$timestamp$method$path$body"
|
||||
|
||||
// 使用 HMAC-SHA256 生成签名
|
||||
val signature = generateSignature(signString, apiSecret)
|
||||
|
||||
// 构建新的请求,添加认证头
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.header("X-API-KEY", apiKey)
|
||||
.header("X-API-SIGN", signature)
|
||||
.header("X-API-TIMESTAMP", timestamp)
|
||||
.header("X-API-PASSPHRASE", apiPassphrase)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HMAC-SHA256 生成签名
|
||||
*/
|
||||
private fun generateSignature(message: String, secret: String): String {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
|
||||
mac.init(secretKeySpec)
|
||||
val hash = mac.doFinal(message.toByteArray())
|
||||
return Base64.getEncoder().encodeToString(hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
/**
|
||||
* Retrofit 客户端工厂
|
||||
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
|
||||
*/
|
||||
@Component
|
||||
class RetrofitFactory(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* 创建带认证的 Polymarket CLOB API 客户端
|
||||
* @param apiKey API Key
|
||||
* @param apiSecret API Secret
|
||||
* @param apiPassphrase API Passphrase
|
||||
* @return PolymarketClobApi 客户端
|
||||
*/
|
||||
fun createClobApi(
|
||||
apiKey: String,
|
||||
apiSecret: String,
|
||||
apiPassphrase: String
|
||||
): PolymarketClobApi {
|
||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase)
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Ethereum RPC API 客户端
|
||||
* @param rpcUrl RPC 节点 URL
|
||||
* @return EthereumRpcApi 客户端
|
||||
*/
|
||||
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(rpcUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(EthereumRpcApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 非法的BigDecimal常量,用于表示转换失败的情况
|
||||
*/
|
||||
val IllegalBigDecimal = BigDecimal("0")
|
||||
|
||||
/**
|
||||
* 非法的BigInteger常量,用于表示转换失败的情况
|
||||
*/
|
||||
val IllegalBigInteger = BigInteger("0")
|
||||
|
||||
/**
|
||||
* 安全转换为BigDecimal的扩展函数
|
||||
* 将任意类型安全地转换为BigDecimal,转换失败时返回IllegalBigDecimal
|
||||
* @return 转换后的BigDecimal值,失败时返回IllegalBigDecimal
|
||||
*/
|
||||
fun Any?.toSafeBigDecimal(): BigDecimal {
|
||||
return try {
|
||||
if (this is BigDecimal) {
|
||||
return this
|
||||
}
|
||||
if (this is BigInteger) {
|
||||
return this.toBigDecimal()
|
||||
}
|
||||
if (this is Number) {
|
||||
return BigDecimal.valueOf(this.toDouble())
|
||||
}
|
||||
BigDecimal(this.toString())
|
||||
} catch (t: Throwable) {
|
||||
IllegalBigDecimal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为BigInteger的扩展函数
|
||||
* 将字符串安全地转换为BigInteger,转换失败时返回IllegalBigInteger
|
||||
* @return 转换后的BigInteger值,失败时返回IllegalBigInteger
|
||||
*/
|
||||
fun String?.toSafeBigInteger(): BigInteger {
|
||||
return try {
|
||||
BigInteger(this.orEmpty())
|
||||
} catch (t: Throwable) {
|
||||
IllegalBigInteger
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为Long的扩展函数
|
||||
* 将字符串安全地转换为Long,转换失败时返回0
|
||||
* @return 转换后的Long值,失败时返回0
|
||||
*/
|
||||
fun String?.toSafeLong(): Long {
|
||||
return try {
|
||||
this?.toLong() ?: 0
|
||||
} catch (t: Throwable) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为Int的扩展函数
|
||||
* 将任意类型安全地转换为Int,转换失败时返回0
|
||||
* @return 转换后的Int值,失败时返回0
|
||||
*/
|
||||
fun Any?.toSafeInt(): Int {
|
||||
return try {
|
||||
if (this is Number) {
|
||||
this.toInt()
|
||||
} else {
|
||||
this?.toString().toSafeBigDecimal().toInt()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为Double的扩展函数
|
||||
* 将任意类型安全地转换为Double,转换失败时返回0.0
|
||||
* @return 转换后的Double值,失败时返回0.0
|
||||
*/
|
||||
fun Any?.toSafeDouble(): Double {
|
||||
return try {
|
||||
when (this) {
|
||||
is Number -> this.toDouble()
|
||||
is Boolean -> if (this) 1.0 else 0.0
|
||||
else -> this?.toString().toSafeBigDecimal().toDouble()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
/**
|
||||
* 获取环境变量的扩展函数
|
||||
* @param name 环境变量名称
|
||||
* @return 环境变量值,不存在时返回空字符串
|
||||
*/
|
||||
fun getEnv(name: String) = System.getenv(name).orEmpty()
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.wrbug.polymarketbot.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.java_websocket.client.WebSocketClient
|
||||
import org.java_websocket.handshake.ServerHandshake
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Polymarket WebSocket 客户端
|
||||
* 用于连接到 Polymarket RTDS
|
||||
*/
|
||||
class PolymarketWebSocketClient(
|
||||
serverUri: URI,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val sessionId: String,
|
||||
private val onMessage: (String) -> Unit
|
||||
) : WebSocketClient(serverUri) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
|
||||
|
||||
override fun onOpen(handshakedata: ServerHandshake?) {
|
||||
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
|
||||
}
|
||||
|
||||
override fun onMessage(message: String?) {
|
||||
if (message != null) {
|
||||
logger.debug("收到 Polymarket 消息: $sessionId, $message")
|
||||
onMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClose(code: Int, reason: String?, remote: Boolean) {
|
||||
logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote")
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
fun closeConnection() {
|
||||
if (isOpen) {
|
||||
try {
|
||||
closeBlocking()
|
||||
} catch (e: Exception) {
|
||||
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(ex: Exception?) {
|
||||
logger.error("Polymarket WebSocket 错误: $sessionId, ${ex?.message}", ex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到 Polymarket
|
||||
*/
|
||||
fun sendMessage(message: String) {
|
||||
if (isOpen) {
|
||||
try {
|
||||
send(message)
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送消息失败: $sessionId, ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
fun isConnected(): Boolean {
|
||||
return isOpen
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.wrbug.polymarketbot.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.*
|
||||
import java.net.URI
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Polymarket WebSocket 处理器
|
||||
* 转发前端 WebSocket 连接到 Polymarket RTDS
|
||||
*/
|
||||
@Component
|
||||
class PolymarketWebSocketHandler(
|
||||
private val objectMapper: ObjectMapper
|
||||
) : WebSocketHandler {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
|
||||
// 存储客户端会话和对应的 Polymarket 连接的映射
|
||||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
||||
private val polymarketConnections = ConcurrentHashMap<String, PolymarketWebSocketClient>()
|
||||
|
||||
override fun afterConnectionEstablished(session: WebSocketSession) {
|
||||
logger.info("客户端连接建立: ${session.id}")
|
||||
clientSessions[session.id] = session
|
||||
|
||||
try {
|
||||
// 创建到 Polymarket 的 WebSocket 连接
|
||||
val polymarketClient = PolymarketWebSocketClient(
|
||||
URI(polymarketWsUrl),
|
||||
objectMapper,
|
||||
session.id
|
||||
) { message ->
|
||||
// 当收到 Polymarket 消息时,转发给客户端
|
||||
forwardToClient(session.id, message)
|
||||
}
|
||||
|
||||
polymarketConnections[session.id] = polymarketClient
|
||||
|
||||
// 异步连接,不阻塞
|
||||
try {
|
||||
polymarketClient.connect()
|
||||
logger.info("正在连接到 Polymarket RTDS: ${session.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("启动 Polymarket 连接失败: ${e.message}", e)
|
||||
// 连接失败时清理资源
|
||||
cleanup(session.id)
|
||||
try {
|
||||
session.close(CloseStatus.SERVER_ERROR.withReason("无法连接到 Polymarket"))
|
||||
} catch (ex: Exception) {
|
||||
logger.error("关闭客户端连接失败: ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建 Polymarket 客户端失败: ${e.message}", e)
|
||||
try {
|
||||
session.close(CloseStatus.SERVER_ERROR.withReason("无法创建连接"))
|
||||
} catch (ex: Exception) {
|
||||
logger.error("关闭客户端连接失败: ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
|
||||
logger.debug("收到客户端消息: ${session.id}, ${message.payload}")
|
||||
|
||||
val polymarketClient = polymarketConnections[session.id]
|
||||
if (polymarketClient != null) {
|
||||
if (polymarketClient.isConnected()) {
|
||||
// 将客户端消息转发给 Polymarket
|
||||
try {
|
||||
polymarketClient.sendMessage(message.payload.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.error("转发消息到 Polymarket 失败: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
logger.warn("Polymarket 连接未就绪,消息将被丢弃: ${session.id}")
|
||||
}
|
||||
} else {
|
||||
logger.warn("Polymarket 连接不存在: ${session.id}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
|
||||
logger.error("WebSocket 传输错误: ${session.id}, ${exception.message}", exception)
|
||||
cleanup(session.id)
|
||||
}
|
||||
|
||||
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
|
||||
logger.info("客户端连接关闭: ${session.id}, 状态: $closeStatus")
|
||||
cleanup(session.id)
|
||||
}
|
||||
|
||||
override fun supportsPartialMessages(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发消息给客户端
|
||||
*/
|
||||
private fun forwardToClient(sessionId: String, message: String) {
|
||||
val session = clientSessions[sessionId]
|
||||
if (session != null && session.isOpen) {
|
||||
try {
|
||||
session.sendMessage(TextMessage(message))
|
||||
} catch (e: Exception) {
|
||||
logger.error("转发消息给客户端失败: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
logger.warn("客户端会话不存在或已关闭: $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
private fun cleanup(sessionId: String) {
|
||||
try {
|
||||
// 关闭 Polymarket 连接
|
||||
val polymarketClient = polymarketConnections.remove(sessionId)
|
||||
if (polymarketClient != null) {
|
||||
try {
|
||||
if (polymarketClient.isConnected()) {
|
||||
polymarketClient.closeConnection()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("关闭 Polymarket 连接失败: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除客户端会话
|
||||
clientSessions.remove(sessionId)
|
||||
logger.debug("已清理资源: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理资源时发生错误: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 应用配置
|
||||
spring.application.name=polymarket-bot-backend
|
||||
|
||||
# 数据源配置
|
||||
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
||||
spring.datasource.username=${DB_USERNAME:root}
|
||||
spring.datasource.password=${DB_PASSWORD:11111111}
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
# HikariCP 连接池配置
|
||||
spring.datasource.hikari.maximum-pool-size=10
|
||||
spring.datasource.hikari.minimum-idle=2
|
||||
spring.datasource.hikari.connection-timeout=30000
|
||||
|
||||
# JPA 配置
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
|
||||
|
||||
# Flyway 配置
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.locations=classpath:db/migration
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
|
||||
# 服务器配置
|
||||
server.port=${SERVER_PORT:8000}
|
||||
|
||||
# 日志配置
|
||||
logging.level.root=INFO
|
||||
logging.level.com.wrbug.polymarketbot=DEBUG
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||
|
||||
# Polymarket API 配置
|
||||
polymarket.clob.base-url=https://clob.polymarket.com
|
||||
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
|
||||
# Ethereum RPC 配置(用于查询链上余额)
|
||||
# 可选:如果未配置,将无法查询 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}
|
||||
|
||||
# 加密配置
|
||||
crypto.secret.key=${CRYPTO_SECRET_KEY:wrbug123}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 创建账户表
|
||||
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='跟单系统账户表';
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- 添加 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,4 @@
|
||||
-- 添加代理地址字段到账户表
|
||||
ALTER TABLE copy_trading_accounts
|
||||
ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address;
|
||||
|
||||
Reference in New Issue
Block a user