feat: 实现订单推送服务和 WebSocket 重连机制

- 实现订单推送服务,支持多账户订单实时推送
- 添加 WebSocket 自动重连机制,支持指数退避策略
- 修复订单详情接口 L2 认证问题,通过 PolymarketClobService 获取
- 配置 Gson lenient 模式,支持解析格式不严格的 JSON
- 添加响应日志拦截器,便于调试 API 响应问题
- 使用 Gamma API 获取市场信息,支持通过 condition_ids 查询
- 修复字段映射问题,使用 @SerializedName 替代 @JsonProperty
- 订单推送消息包含订单详情和市场信息
This commit is contained in:
WrBug
2025-11-27 14:51:46 +08:00
parent 47357995af
commit 5b458b9b0c
21 changed files with 1765 additions and 66 deletions
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.api
import com.google.gson.annotations.SerializedName
import retrofit2.Response
import retrofit2.http.*
@@ -59,11 +60,15 @@ interface PolymarketClobApi {
/**
* 获取订单信息
* 文档: https://docs.polymarket.com/developers/CLOB/orders/get-order
* 端点: GET /data/order/{order_hash}
* 需要 L2 认证
* 注意:实际返回格式是直接返回 OpenOrder 对象,不是包装在 { "order": ... } 中
*/
@GET("/orders/{orderId}")
@GET("/data/order/{orderId}")
suspend fun getOrder(
@Path("orderId") orderId: String
): Response<OrderResponse>
): Response<OpenOrder>
/**
* 获取活跃订单
@@ -191,6 +196,36 @@ data class OrderResponse(
val createdAt: String // ISO 8601 格式字符串
)
/**
* OpenOrder 对象(根据实际 API 返回)
* 文档: https://docs.polymarket.com/developers/CLOB/orders/get-order
* 注意:实际返回格式是直接返回订单对象,不是包装在 { "order": ... } 中
*/
data class OpenOrder(
val id: String, // order id
val status: String, // order current status (LIVE, FILLED, CANCELLED, etc.)
val owner: String, // api key
@SerializedName("maker_address")
val makerAddress: String, // maker address (funder)
val market: String, // market id (condition id)
@SerializedName("asset_id")
val assetId: String, // token id
val side: String, // BUY or SELL
@SerializedName("original_size")
val originalSize: String, // original order size at placement
@SerializedName("size_matched")
val sizeMatched: String, // size of order that has been matched/filled
val price: String, // price
val outcome: String, // human readable outcome the order is for
val expiration: String, // unix timestamp when the order expired, 0 if it does not expire
@SerializedName("order_type")
val orderType: String, // order type (GTC, FOK, GTD)
@SerializedName("associate_trades")
val associateTrades: List<String>? = null, // any Trade id the order has been partially included in
@SerializedName("created_at")
val createdAt: Long // unix timestamp when the order was created
)
data class CancelOrderResponse(
val orderId: String,
val status: String
@@ -0,0 +1,56 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Polymarket Gamma API 接口定义
* 用于查询市场信息
* Base URL: https://gamma-api.polymarket.com
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
*/
interface PolymarketGammaApi {
/**
* 根据 condition ID 列表获取市场信息
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x..."
* @param includeTag 是否包含标签信息
* @return 市场信息数组
*/
@GET("/markets")
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
): Response<List<MarketResponse>>
}
/**
* 市场响应(根据 Gamma API 文档)
*/
data class MarketResponse(
val id: String? = null,
val question: String? = null, // 市场名称
val conditionId: String? = null,
val slug: String? = null,
val icon: String? = null,
val image: String? = null,
val description: String? = null,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val volume: String? = null,
val liquidity: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val outcomes: String? = null,
val outcomePrices: String? = null,
val volumeNum: Double? = null,
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
)
@@ -7,7 +7,8 @@ data class AccountImportRequest(
val privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null,
val isDefault: Boolean = false
val isDefault: Boolean = false,
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
)
/**
@@ -16,7 +17,8 @@ data class AccountImportRequest(
data class AccountUpdateRequest(
val accountId: Long,
val accountName: String? = null,
val isDefault: Boolean? = null
val isDefault: Boolean? = null,
val isEnabled: Boolean? = null // 是否启用(用于订单推送等功能的开关)
)
/**
@@ -55,6 +57,7 @@ data class AccountDto(
val walletAddress: String,
val accountName: String?,
val isDefault: Boolean,
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.dto
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Polymarket Order Message DTO
* 根据 https://docs.polymarket.com/developers/CLOB/websocket/user-channel#order-message
*/
data class OrderMessageDto(
@JsonProperty("asset_id")
val assetId: String, // asset ID (token ID) of order
@JsonProperty("associate_trades")
val associateTrades: List<String>?, // array of ids referencing trades that the order has been included in
@JsonProperty("event_type")
val eventType: String, // "order"
val id: String, // order id
val market: String, // condition ID of market
@JsonProperty("order_owner")
val orderOwner: String, // owner of order
@JsonProperty("original_size")
val originalSize: String, // original order size
val outcome: String, // outcome
val owner: String, // owner of orders
val price: String, // price of order
val side: String, // BUY/SELL
@JsonProperty("size_matched")
val sizeMatched: String, // size of order that has been matched
val timestamp: String, // time of event
val type: String // PLACEMENT/UPDATE/CANCELLATION
)
/**
* 订单推送消息(统一格式)
*/
data class OrderPushMessage(
val accountId: Long, // 账户 ID
val accountName: String, // 账户名称
val order: OrderMessageDto, // 订单信息(来自 WebSocket
val orderDetail: OrderDetailDto? = null, // 订单详情(通过 API 获取)
val timestamp: Long = System.currentTimeMillis() // 推送时间戳
)
/**
* 订单详情(通过 API 获取)
*/
data class OrderDetailDto(
val id: String, // 订单 ID
val market: String, // 市场 ID (condition ID)
val side: String, // BUY/SELL
val price: String, // 价格
val size: String, // 订单大小
val filled: String, // 已成交数量
val status: String, // 订单状态
val createdAt: String, // 创建时间(ISO 8601 格式)
val marketName: String? = null, // 市场名称(通过 Data API 获取)
val marketSlug: String? = null, // 市场 slug
val marketIcon: String? = null // 市场图标
)
@@ -37,6 +37,9 @@ data class Account(
@Column(name = "is_default", nullable = false)
val isDefault: Boolean = false, // 是否默认账户
@Column(name = "is_enabled", nullable = false)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -22,7 +22,8 @@ class AccountService(
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService,
private val apiKeyService: PolymarketApiKeyService
private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -112,12 +113,16 @@ class AccountService(
apiPassphrase = apiKeyCreds.passphrase,
accountName = request.accountName,
isDefault = request.isDefault,
isEnabled = request.isEnabled,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(account)
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}")
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}, 启用状态: ${saved.isEnabled}")
// 刷新订单推送订阅(如果账户启用且有 API 凭证)
orderPushService.refreshSubscriptions()
Result.success(toDto(saved))
} catch (e: Exception) {
@@ -147,14 +152,21 @@ class AccountService(
}
}
// 更新启用状态
val updatedIsEnabled = request.isEnabled ?: account.isEnabled
val updated = account.copy(
accountName = updatedAccountName,
isDefault = updatedIsDefault,
isEnabled = updatedIsEnabled,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("成功更新账户: ${saved.id}")
logger.info("成功更新账户: ${saved.id}, 启用状态: ${saved.isEnabled}")
// 刷新订单推送订阅(账户状态变更时)
orderPushService.refreshSubscriptions()
Result.success(toDto(saved))
} catch (e: Exception) {
@@ -194,6 +206,9 @@ class AccountService(
accountRepository.delete(account)
logger.info("成功删除账户: $accountId")
// 刷新订单推送订阅(账户删除时)
orderPushService.refreshSubscriptions()
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除账户失败", e)
@@ -370,6 +385,7 @@ class AccountService(
walletAddress = account.walletAddress,
accountName = account.accountName,
isDefault = account.isDefault,
isEnabled = account.isEnabled,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
@@ -0,0 +1,458 @@
package com.wrbug.polymarketbot.service
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 订单推送服务
* 为每个账户建立到 Polymarket User Channel 的连接,接收订单消息并推送给前端
*/
@Service
class OrderPushService(
private val accountRepository: AccountRepository,
private val objectMapper: ObjectMapper,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory // 用于创建 Gamma API 客户端(不需要认证)
) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@Value("\${polymarket.rtds.ws-url}")
private lateinit var polymarketWsUrl: String
// 存储账户 ID 和对应的 WebSocket 连接
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
// 存储账户 ID 和对应的推送回调:accountId -> Set<(OrderPushMessage) -> Unit>
private val accountCallbacks = ConcurrentHashMap<Long, MutableSet<(OrderPushMessage) -> Unit>>()
// 协程作用域
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
/**
* 初始化服务
* 为所有有 API Key 的账户建立连接
*/
@PostConstruct
fun init() {
logger.info("订单推送服务已初始化")
scope.launch {
connectAllAccounts()
}
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
logger.info("停止订单推送服务")
accountConnections.values.forEach { client ->
try {
if (client.isConnected()) {
client.closeConnection()
}
} catch (e: Exception) {
logger.error("关闭账户连接失败: ${e.message}", e)
}
}
accountConnections.clear()
accountCallbacks.clear()
scope.cancel()
}
/**
* 为所有有 API Key 且启用的账户建立连接
*/
private suspend fun connectAllAccounts() {
val accounts = accountRepository.findAll()
accounts.forEach { account ->
if (hasApiCredentials(account) && account.isEnabled) {
connectAccount(account)
}
}
}
/**
* 订阅所有启用的账户
*/
fun subscribeAllEnabled(callback: (OrderPushMessage) -> Unit) {
logger.info("订阅所有启用账户的订单推送")
val accounts = accountRepository.findAll()
accounts.forEach { account ->
if (hasApiCredentials(account) && account.isEnabled) {
val accountId = account.id!!
accountCallbacks.getOrPut(accountId) { mutableSetOf() }.add(callback)
// 如果账户连接不存在,建立连接
if (!accountConnections.containsKey(accountId)) {
connectAccount(account)
}
}
}
}
/**
* 取消订阅所有账户
*/
fun unsubscribeAll(callback: (OrderPushMessage) -> Unit) {
logger.info("取消订阅所有账户的订单推送")
accountCallbacks.values.forEach { callbacks ->
callbacks.remove(callback)
}
}
/**
* 重新订阅所有启用的账户(用于账户状态变更时调用)
*/
fun refreshSubscriptions() {
logger.info("刷新所有账户的订阅状态")
val accounts = accountRepository.findAll()
val enabledAccountIds = accounts
.filter { hasApiCredentials(it) && it.isEnabled }
.map { it.id!! }
.toSet()
// 断开已禁用或删除的账户连接
accountConnections.keys.forEach { accountId ->
if (!enabledAccountIds.contains(accountId)) {
disconnectAccount(accountId)
}
}
// 为启用的账户建立连接(如果还没有)
accounts.forEach { account ->
if (hasApiCredentials(account) && account.isEnabled) {
val accountId = account.id!!
if (!accountConnections.containsKey(accountId)) {
connectAccount(account)
}
}
}
}
/**
* 检查账户是否有 API 凭证
*/
private fun hasApiCredentials(account: Account): Boolean {
return account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null &&
account.apiKey!!.isNotBlank() &&
account.apiSecret!!.isNotBlank() &&
account.apiPassphrase!!.isNotBlank()
}
/**
* 为指定账户建立 User Channel 连接
*/
fun connectAccount(account: Account) {
if (!hasApiCredentials(account)) {
logger.warn("账户 ${account.id} 没有 API 凭证,无法建立连接")
return
}
if (!account.isEnabled) {
logger.debug("账户 ${account.id} 未启用,跳过连接")
return
}
if (accountConnections.containsKey(account.id)) {
logger.debug("账户 ${account.id} 已存在连接,跳过")
return
}
scope.launch {
try {
// 连接到 Polymarket RTDS User Channel
// 根据官方文档:https://docs.polymarket.com/quickstart/websocket/WSS-Quickstart
// URL 格式为: wss://ws-subscriptions-clob.polymarket.com/ws/user
val wsUrl = "$polymarketWsUrl/ws/user"
// 创建客户端,在连接建立后立即发送订阅消息
val client = PolymarketWebSocketClient(
url = wsUrl,
sessionId = "account-${account.id}",
onMessage = { message -> handleMessage(account, message) },
onOpen = {
// 首次连接建立后立即发送订阅消息
val currentClient = accountConnections[account.id!!]
if (currentClient != null) {
try {
sendSubscribeMessage(currentClient, account)
logger.info("已为账户 ${account.id} (${account.accountName ?: account.walletAddress}) 建立 User Channel 连接并发送订阅消息")
} catch (e: Exception) {
logger.error("发送订阅消息失败: account=${account.id}, ${e.message}", e)
// 如果订阅失败,关闭连接(会触发重连)
currentClient.closeConnection()
accountConnections.remove(account.id)
}
} else {
logger.warn("账户 ${account.id} 的连接不存在,无法发送订阅消息")
}
},
onReconnect = {
// 重连后重新发送订阅消息
val currentClient = accountConnections[account.id!!]
if (currentClient != null) {
try {
sendSubscribeMessage(currentClient, account)
logger.info("账户 ${account.id} 重连成功,已重新发送订阅消息")
} catch (e: Exception) {
logger.error("重连后发送订阅消息失败: account=${account.id}, ${e.message}", e)
}
}
}
)
accountConnections[account.id!!] = client
client.connect()
} catch (e: Exception) {
logger.error("为账户 ${account.id} 建立连接失败: ${e.message}", e)
accountConnections.remove(account.id)
}
}
}
/**
* 发送订阅消息到 Polymarket User Channel
* 根据 https://docs.polymarket.com/developers/CLOB/websocket/user-channel
* 参考 clob-client/examples/socketConnection.ts
*
* 订阅消息格式(与 clob-client 保持一致):
* {
* "auth": { "apiKey": "...", "secret": "...", "passphrase": "..." },
* "type": "user",
* "markets": [], // 空数组表示订阅所有市场,也可以指定 condition IDs
* "assets_ids": [],
* "initial_dump": true
* }
*/
private fun sendSubscribeMessage(client: PolymarketWebSocketClient, account: Account) {
try {
val subscribeMessage = mapOf(
"auth" to mapOf(
"apiKey" to account.apiKey,
"secret" to account.apiSecret,
"passphrase" to account.apiPassphrase
),
"type" to "user",
"markets" to emptyList<String>(), // 空数组表示订阅所有市场
"assets_ids" to emptyList<String>(),
"initial_dump" to true
)
val json = objectMapper.writeValueAsString(subscribeMessage)
client.sendMessage(json)
logger.info("已发送 User Channel 订阅消息: account=${account.id}, apiKey=${account.apiKey?.take(10)}...")
} catch (e: Exception) {
logger.error("发送订阅消息失败: account=${account.id}, ${e.message}", e)
}
}
/**
* 处理收到的消息
*/
private fun handleMessage(account: Account, message: String) {
try {
// 处理心跳响应(PONG),直接返回
if (message.trim() == "PONG" || message.trim() == "pong") {
logger.debug("收到 PONG 响应: account=${account.id}")
return
}
// 尝试解析 JSON 消息
val messageMap = objectMapper.readValue(message, Map::class.java) as Map<*, *>
val eventType = messageMap["event_type"] as? String
// 只处理 order 类型的消息
if (eventType == "order") {
val orderMessage = objectMapper.readValue(message, OrderMessageDto::class.java)
// 异步获取订单详情
scope.launch {
val orderDetail = fetchOrderDetail(account, orderMessage.id, orderMessage.market)
val pushMessage = OrderPushMessage(
accountId = account.id!!,
accountName = account.accountName ?: account.walletAddress,
order = orderMessage,
orderDetail = orderDetail
)
// 推送给所有订阅者
accountCallbacks[account.id]?.forEach { callback ->
try {
callback(pushMessage)
} catch (e: Exception) {
logger.error("推送订单消息失败: account=${account.id}, ${e.message}", e)
}
}
}
} else {
// 记录其他类型的消息(用于调试)
logger.debug("收到非订单消息: account=${account.id}, eventType=$eventType")
}
} catch (e: Exception) {
// 如果解析失败,可能是非 JSON 消息(如 PONG),记录为 debug 级别
if (message.trim() == "PONG" || message.trim() == "pong") {
logger.debug("收到 PONG 响应: account=${account.id}")
} else {
logger.error("处理订单消息失败: account=${account.id}, message=${message.take(100)}, ${e.message}", e)
}
}
}
/**
* 获取订单详情
* 通过 PolymarketClobService 获取订单详情
*/
private suspend fun fetchOrderDetail(
account: Account,
orderId: String,
conditionId: String? = null
): OrderDetailDto? {
return try {
// 检查账户是否有 API 凭证
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户 ${account.id} 未配置 API 凭证,无法获取订单详情")
return null
}
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证)
val result = clobService.getOrder(
orderId = orderId,
apiKey = account.apiKey!!,
apiSecret = account.apiSecret!!,
apiPassphrase = account.apiPassphrase!!,
walletAddress = account.walletAddress
)
result.fold(
onSuccess = { openOrder ->
// 获取市场信息(通过 Gamma API)
val marketInfo = fetchMarketInfo(conditionId ?: openOrder.market)
// 转换为 DTO
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
OrderDetailDto(
id = openOrder.id,
market = openOrder.market,
side = openOrder.side,
price = openOrder.price,
size = openOrder.originalSize, // 使用 original_size
filled = openOrder.sizeMatched, // 使用 size_matched
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = marketInfo?.question,
marketSlug = marketInfo?.slug,
marketIcon = marketInfo?.icon
)
},
onFailure = { e ->
logger.warn("获取订单详情失败: account=${account.id}, orderId=$orderId, ${e.message}")
null
}
)
} catch (e: Exception) {
logger.error("获取订单详情异常: account=${account.id}, orderId=$orderId, ${e.message}", e)
null
}
}
/**
* 获取市场信息(通过 Gamma API)
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
*
* 使用 /markets 接口,通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID(如 "0x..."
*/
private suspend fun fetchMarketInfo(conditionId: String): com.wrbug.polymarketbot.api.MarketResponse? {
return try {
// 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi()
// 调用 Gamma API 获取市场信息
// 使用 /markets 接口,通过 condition_ids 查询参数
val response = gammaApi.listMarkets(
conditionIds = listOf(conditionId),
includeTag = null
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val market = markets.first()
logger.debug("获取市场信息成功: conditionId=$conditionId, question=${market.question}")
return market
} else {
logger.debug("未找到市场信息: conditionId=$conditionId")
return null
}
} else {
logger.debug("获取市场信息失败: conditionId=$conditionId, code=${response.code()}, message=${response.message()}")
null
}
} catch (e: Exception) {
logger.debug("获取市场信息异常: conditionId=$conditionId, ${e.message}")
null
}
}
/**
* 订阅账户的订单推送(保留用于向后兼容)
*/
fun subscribe(accountId: Long, callback: (OrderPushMessage) -> Unit) {
logger.info("订阅账户订单推送: $accountId")
accountCallbacks.getOrPut(accountId) { mutableSetOf() }.add(callback)
// 如果账户连接不存在,尝试建立连接
if (!accountConnections.containsKey(accountId)) {
val account = accountRepository.findById(accountId).orElse(null)
if (account != null && hasApiCredentials(account) && account.isEnabled) {
connectAccount(account)
}
}
}
/**
* 取消订阅账户的订单推送(保留用于向后兼容)
*/
fun unsubscribe(accountId: Long, callback: (OrderPushMessage) -> Unit) {
logger.info("取消订阅账户订单推送: $accountId")
accountCallbacks[accountId]?.remove(callback)
// 如果没有订阅者了,可以考虑关闭连接(但暂时保持连接,以便后续订阅)
}
/**
* 断开指定账户的连接
*/
fun disconnectAccount(accountId: Long) {
logger.info("断开账户连接: $accountId")
val client = accountConnections.remove(accountId)
client?.let {
try {
if (it.isConnected()) {
it.closeConnection()
}
} catch (e: Exception) {
logger.error("关闭账户连接失败: $accountId, ${e.message}", e)
}
}
accountCallbacks.remove(accountId)
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -10,7 +11,8 @@ import org.springframework.stereotype.Service
*/
@Service
class PolymarketClobService(
private val clobApi: PolymarketClobApi
private val clobApi: PolymarketClobApi, // 用于不需要认证的接口
private val retrofitFactory: RetrofitFactory // 用于创建带认证的客户端
) {
private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java)
@@ -83,6 +85,45 @@ class PolymarketClobService(
}
}
/**
* 获取订单详情(需要 L2 认证)
* 文档: https://docs.polymarket.com/developers/CLOB/orders/get-order
*
* @param orderId 订单 ID
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
* @param walletAddress 钱包地址(用于 POLY_ADDRESS 请求头)
* @return 订单详情
*/
suspend fun getOrder(
orderId: String,
apiKey: String,
apiSecret: String,
apiPassphrase: String,
walletAddress: String
): Result<OpenOrder> {
return try {
// 创建带 L2 认证的 API 客户端
val authenticatedClobApi = retrofitFactory.createClobApi(
apiKey = apiKey,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase,
walletAddress = walletAddress
)
val response = authenticatedClobApi.getOrder(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)
}
}
/**
* 获取活跃订单
*/
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
@@ -14,7 +15,8 @@ import java.util.concurrent.ConcurrentHashMap
*/
@Service
class WebSocketSubscriptionService(
private val positionPushService: PositionPushService
private val positionPushService: PositionPushService,
private val orderPushService: OrderPushService
) {
private val logger = LoggerFactory.getLogger(WebSocketSubscriptionService::class.java)
@@ -31,6 +33,9 @@ class WebSocketSubscriptionService(
// 存储每个频道的订阅会话数:channel -> Set<sessionId>
private val channelSubscriptions = ConcurrentHashMap<String, MutableSet<String>>()
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
/**
* 注册会话
*/
@@ -52,6 +57,9 @@ class WebSocketSubscriptionService(
unsubscribe(sessionId, channel)
}
// 清理 order 频道的回调
orderChannelCallbacks.remove(sessionId)
sessionCallbacks.remove(sessionId)
}
@@ -92,6 +100,15 @@ class WebSocketSubscriptionService(
}
}
}
"order" -> {
// 订单推送:自动订阅所有启用的账户
val callback: (OrderPushMessage) -> Unit = { message ->
pushData(sessionId, channel, message)
}
orderChannelCallbacks[sessionId] = callback
orderPushService.subscribeAllEnabled(callback)
logger.info("已订阅所有启用账户的订单推送: $sessionId")
}
else -> {
logger.warn("未知的频道: $channel")
sendSubscribeAck(sessionId, channel, false, "未知的频道")
@@ -112,6 +129,14 @@ class WebSocketSubscriptionService(
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
when (channel) {
"position" -> positionPushService.unsubscribe(sessionId)
"order" -> {
// 取消订阅所有账户的订单推送
val callback = orderChannelCallbacks.remove(sessionId)
if (callback != null) {
orderPushService.unsubscribeAll(callback)
logger.debug("已取消订阅订单推送: $sessionId -> $channel")
}
}
}
}
@@ -10,6 +10,19 @@ import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.*
/**
* 获取代理配置(用于 WebSocket 和 HTTP 请求)
* @return Proxy 对象,如果未启用代理则返回 null
*/
fun getProxyConfig(): Proxy? {
if (getEnv("ENABLE_PROXY") != "1") {
return null
}
val host = getEnv("PROXY_HOST").ifEmpty { "127.0.0.1" }
val port = getEnv("PROXY_PORT").toIntOrNull() ?: 8888
return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
}
/**
* 创建OkHttpClient客户端
* @return OkHttpClient.Builder
@@ -99,13 +99,6 @@ class PolymarketAuthInterceptor(
.header("User-Agent", "@polymarket/clob-client")
.header("Accept", "*/*")
.header("Connection", "keep-alive")
.header("Content-Type", "application/json")
.apply {
// GET 请求添加 Accept-Encoding: gzip
if (method == "GET") {
header("Accept-Encoding", "gzip")
}
}
// 如果有请求体,重新设置请求体
if (newRequestBody != null) {
@@ -1,11 +1,19 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import okhttp3.Interceptor
import okhttp3.Response
import okio.Buffer
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
/**
* Retrofit 客户端工厂
@@ -14,7 +22,9 @@ import retrofit2.converter.gson.GsonConverterFactory
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String
private val clobBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String
) {
/**
@@ -33,14 +43,49 @@ class RetrofitFactory(
): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
/**
* 创建不带认证的 Polymarket CLOB API 客户端
* 用于不需要认证的查询接口
* @return PolymarketClobApi 客户端
*/
fun createClobApiWithoutAuth(): PolymarketClobApi {
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -53,12 +98,83 @@ class RetrofitFactory(
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(rpcUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EthereumRpcApi::class.java)
}
/**
* 创建 Polymarket Gamma API 客户端
* Gamma API 是公开 API,不需要认证
* @return PolymarketGammaApi 客户端
*/
fun createGammaApi(): PolymarketGammaApi {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
}
}
/**
* 响应日志拦截器
* 用于记录 API 响应的原始内容,帮助调试 JSON 解析错误
*/
class ResponseLoggingInterceptor : Interceptor {
private val logger = LoggerFactory.getLogger(ResponseLoggingInterceptor::class.java)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
// 只在响应不成功或可能有问题时记录响应体
if (response.isSuccessful) {
try {
// 使用 peekBody 读取响应体,避免消费响应流
// 只读取前 2KB,避免内存问题
val responseBody = response.peekBody(2048)
val responseBodyString = responseBody.string()
// 检查是否是有效的 JSON
val isJson = responseBodyString.trim().startsWith("{") ||
responseBodyString.trim().startsWith("[")
if (!isJson || !response.isSuccessful) {
logger.warn(
"API 响应异常: method=${request.method}, url=${request.url}, " +
"code=${response.code}, isJson=$isJson, " +
"responseBody=${responseBodyString.take(500)}"
)
}
} catch (e: Exception) {
logger.debug("读取响应体失败: ${e.message}")
}
}
return response
}
}
@@ -1,69 +1,269 @@
package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.getProxyConfig
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.slf4j.LoggerFactory
import java.net.URI
/**
* Polymarket WebSocket 客户端
* Polymarket WebSocket 客户端(使用 OkHttp 实现)
* 用于连接到 Polymarket RTDS
* 支持代理配置(通过环境变量 ENABLE_PROXY、PROXY_HOST、PROXY_PORT 控制)
*/
class PolymarketWebSocketClient(
serverUri: URI,
private val objectMapper: ObjectMapper,
private val url: String,
private val sessionId: String,
private val onMessage: (String) -> Unit
) : WebSocketClient(serverUri) {
private val onMessage: (String) -> Unit,
private val onOpen: (() -> Unit)? = null, // 连接建立后的回调,用于发送订阅消息
private val onReconnect: (() -> Unit)? = null // 重连回调,用于重新发送订阅消息
) {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
override fun onOpen(handshakedata: ServerHandshake?) {
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
private var webSocket: WebSocket? = null
private var isConnected = false
private var pingJob: Job? = null
private var reconnectJob: Job? = null
private var shouldReconnect = true // 是否应该自动重连
private var reconnectDelay = 3000L // 重连延迟(毫秒),初始 3 秒
private val okHttpClient: OkHttpClient by lazy {
val proxy = getProxyConfig()
val builder = createClient()
// 如果启用了代理,配置代理
if (proxy != null) {
builder.proxy(proxy)
logger.info("已配置 WebSocket 代理: ${proxy.address()}")
}
builder.build()
}
override fun onMessage(message: String?) {
if (message != null) {
logger.debug("收到 Polymarket 消息: $sessionId, $message")
onMessage(message)
/**
* 连接 WebSocket
*/
fun connect() {
if (webSocket != null && isConnected) {
logger.debug("WebSocket 已连接: $sessionId")
return
}
try {
val request = Request.Builder()
.url(url)
.build()
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
isConnected = true
// 重置重连延迟(连接成功后重置为初始值)
reconnectDelay = 3000L
// 停止重连任务(如果存在)
stopReconnect()
// 连接建立后立即调用回调(用于发送订阅消息)
// 如果是重连,调用 onReconnect;否则调用 onOpen
if (reconnectJob != null) {
// 这是重连,调用 onReconnect
onReconnect?.invoke()
} else {
// 这是首次连接,调用 onOpen
onOpen?.invoke()
}
// 启动 PING 保活机制(每 10 秒发送一次 PING)
startPing()
}
override fun onMessage(webSocket: WebSocket, text: String) {
logger.debug("收到 Polymarket 消息: $sessionId, $text")
onMessage(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
logger.debug("收到 Polymarket 二进制消息: $sessionId")
onMessage(bytes.utf8())
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
logger.info("Polymarket 连接正在关闭: $sessionId, code: $code, reason: $reason")
isConnected = false
stopPing()
// 如果不是正常关闭(code != 1000),尝试重连
if (code != 1000 && shouldReconnect) {
scheduleReconnect()
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
logger.info("Polymarket 连接已关闭: $sessionId, code: $code, reason: $reason")
isConnected = false
stopPing()
// 如果不是正常关闭(code != 1000),尝试重连
if (code != 1000 && shouldReconnect) {
scheduleReconnect()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("Polymarket WebSocket 错误: $sessionId, ${t.message}", t)
if (response != null) {
logger.error("响应码: ${response.code}, 响应消息: ${response.message}")
try {
response.body?.let { body ->
val bodyString = body.string()
if (bodyString.isNotEmpty()) {
logger.error("响应体: $bodyString")
}
}
} catch (e: Exception) {
logger.debug("无法读取响应体: ${e.message}")
}
}
isConnected = false
stopPing()
// 连接失败,尝试重连
if (shouldReconnect) {
scheduleReconnect()
}
}
})
logger.info("正在连接到 Polymarket RTDS: $sessionId, URL: $url")
} catch (e: Exception) {
logger.error("创建 WebSocket 连接失败: $sessionId, ${e.message}", e)
throw e
}
}
override fun onClose(code: Int, reason: String?, remote: Boolean) {
logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote")
/**
* 启动 PING 保活机制
* 根据官方文档,每 10 秒发送一次 "PING"
*/
private fun startPing() {
stopPing() // 先停止之前的 PING 任务
pingJob = CoroutineScope(Dispatchers.Default).launch {
while (isActive && isConnected) {
delay(10000) // 10 秒
if (isConnected) {
try {
sendMessage("PING")
logger.debug("已发送 PING: $sessionId")
} catch (e: Exception) {
logger.warn("发送 PING 失败: $sessionId, ${e.message}")
break
}
}
}
}
}
/**
* 停止 PING 保活机制
*/
private fun stopPing() {
pingJob?.cancel()
pingJob = null
}
/**
* 安排重连
* 使用指数退避策略:3秒 -> 6秒 -> 12秒 -> 24秒 -> 最大 60 秒
*/
private fun scheduleReconnect() {
// 如果已经有重连任务在运行,不重复安排
if (reconnectJob != null && reconnectJob!!.isActive) {
return
}
reconnectJob = CoroutineScope(Dispatchers.Default).launch {
try {
delay(reconnectDelay)
// 检查是否应该重连
if (!shouldReconnect) {
logger.info("重连已禁用,停止重连: $sessionId")
return@launch
}
// 如果已经连接,不需要重连
if (isConnected) {
logger.debug("连接已恢复,取消重连: $sessionId")
return@launch
}
logger.info("尝试重连 Polymarket WebSocket: $sessionId, 延迟: ${reconnectDelay}ms")
// 清理旧的连接
webSocket = null
// 重新连接
connect()
// 增加重连延迟(指数退避,最大 60 秒)
reconnectDelay = (reconnectDelay * 2).coerceAtMost(60000L)
} catch (e: Exception) {
logger.error("重连失败: $sessionId, ${e.message}", e)
// 重连失败,继续安排下一次重连
scheduleReconnect()
}
}
}
/**
* 停止重连
*/
private fun stopReconnect() {
reconnectJob?.cancel()
reconnectJob = null
}
/**
* 关闭连接
*/
fun closeConnection() {
if (isOpen) {
try {
closeBlocking()
} catch (e: Exception) {
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
}
try {
shouldReconnect = false // 禁用自动重连
stopReconnect() // 停止重连任务
stopPing()
webSocket?.close(1000, "正常关闭")
webSocket = null
isConnected = false
logger.info("已关闭 WebSocket 连接: $sessionId")
} 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) {
val ws = webSocket
if (ws != null && isConnected) {
try {
send(message)
val sent = ws.send(message)
if (!sent) {
logger.warn("发送消息失败(连接可能已关闭): $sessionId")
throw IllegalStateException("WebSocket 连接已关闭,无法发送消息")
}
} catch (e: Exception) {
logger.error("发送消息失败: $sessionId, ${e.message}", e)
throw e
}
} else {
logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
throw IllegalStateException("WebSocket 未连接")
}
}
@@ -71,7 +271,7 @@ class PolymarketWebSocketClient(
* 检查连接状态
*/
fun isConnected(): Boolean {
return isOpen
return isConnected && webSocket != null
}
}
@@ -1,11 +1,9 @@
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
/**
@@ -13,9 +11,7 @@ import java.util.concurrent.ConcurrentHashMap
* 转发前端 WebSocket 连接到 Polymarket RTDS
*/
@Component
class PolymarketWebSocketHandler(
private val objectMapper: ObjectMapper
) : WebSocketHandler {
class PolymarketWebSocketHandler : WebSocketHandler {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
@@ -33,13 +29,13 @@ class PolymarketWebSocketHandler(
try {
// 创建到 Polymarket 的 WebSocket 连接
val polymarketClient = PolymarketWebSocketClient(
URI(polymarketWsUrl),
objectMapper,
session.id
) { message ->
// 当收到 Polymarket 消息时,转发给客户端
forwardToClient(session.id, message)
}
url = polymarketWsUrl,
sessionId = session.id,
onMessage = { message ->
// 当收到 Polymarket 消息时,转发给客户端
forwardToClient(session.id, message)
}
)
polymarketConnections[session.id] = polymarketClient
@@ -32,8 +32,9 @@ 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.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
polymarket.data-api.base-url=https://data-api.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
# Ethereum RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
@@ -0,0 +1,4 @@
-- 添加是否启用字段到账户表
ALTER TABLE copy_trading_accounts
ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)' AFTER is_default;