From 5b458b9b0c8b9cdfe1a0ee6269c7a57b873ca14b Mon Sep 17 00:00:00 2001 From: WrBug Date: Thu, 27 Nov 2025 14:51:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=9C=8D=E5=8A=A1=E5=92=8C=20WebSocket=20?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现订单推送服务,支持多账户订单实时推送 - 添加 WebSocket 自动重连机制,支持指数退避策略 - 修复订单详情接口 L2 认证问题,通过 PolymarketClobService 获取 - 配置 Gson lenient 模式,支持解析格式不严格的 JSON - 添加响应日志拦截器,便于调试 API 响应问题 - 使用 Gamma API 获取市场信息,支持通过 condition_ids 查询 - 修复字段映射问题,使用 @SerializedName 替代 @JsonProperty - 订单推送消息包含订单详情和市场信息 --- backend/TEST_ORDER_PUSH.md | 183 +++++++ .../polymarketbot/api/PolymarketClobApi.kt | 39 +- .../polymarketbot/api/PolymarketGammaApi.kt | 56 +++ .../com/wrbug/polymarketbot/dto/AccountDto.kt | 7 +- .../polymarketbot/dto/OrderMessageDto.kt | 65 +++ .../com/wrbug/polymarketbot/entity/Account.kt | 3 + .../polymarketbot/service/AccountService.kt | 22 +- .../polymarketbot/service/OrderPushService.kt | 458 ++++++++++++++++++ .../service/PolymarketClobService.kt | 43 +- .../service/WebSocketSubscriptionService.kt | 27 +- .../com/wrbug/polymarketbot/util/OkHttpExt.kt | 13 + .../util/PolymarketAuthInterceptor.kt | 7 - .../polymarketbot/util/RetrofitFactory.kt | 122 ++++- .../websocket/PolymarketWebSocketClient.kt | 260 ++++++++-- .../websocket/PolymarketWebSocketHandler.kt | 20 +- .../src/main/resources/application.properties | 3 +- .../V4__add_is_enabled_to_accounts.sql | 4 + docs/position-sell-order-design.md | 357 ++++++++++++++ frontend/src/App.tsx | 90 +++- frontend/src/hooks/useWebSocket.ts | 4 +- frontend/src/types/index.ts | 48 ++ 21 files changed, 1765 insertions(+), 66 deletions(-) create mode 100644 backend/TEST_ORDER_PUSH.md create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt create mode 100644 backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql create mode 100644 docs/position-sell-order-design.md diff --git a/backend/TEST_ORDER_PUSH.md b/backend/TEST_ORDER_PUSH.md new file mode 100644 index 0000000..2753376 --- /dev/null +++ b/backend/TEST_ORDER_PUSH.md @@ -0,0 +1,183 @@ +# 订单推送功能测试指南 + +## 测试准备 + +### 1. 确保有测试账户 +- 账户必须已导入到系统 +- 账户必须有有效的 API Key、Secret 和 Passphrase +- 可以通过 `/api/accounts/import` 接口导入账户 + +### 2. 启动后端服务 +```bash +cd backend +./gradlew bootRun +``` + +### 3. 检查日志 +启动后,应该看到以下日志: +- `订单推送服务已初始化` +- `已为账户 X (账户名) 建立 User Channel 连接并发送订阅消息` + +## 测试步骤 + +### 测试 1: 检查服务初始化 +1. 启动后端服务 +2. 查看日志,确认 `OrderPushService` 已初始化 +3. 确认所有有 API 凭证的账户都已建立连接 + +**预期结果**: +- 日志显示:`订单推送服务已初始化` +- 对于每个有 API 凭证的账户,日志显示:`已为账户 X 建立 User Channel 连接并发送订阅消息` + +### 测试 2: 通过 WebSocket 订阅订单推送 + +#### 2.1 使用 WebSocket 客户端连接 +```bash +# 使用 wscat 或其他 WebSocket 客户端 +wscat -c ws://localhost:8000/ws +``` + +#### 2.2 发送订阅消息 +```json +{ + "type": 1, + "channel": "order", + "payload": { + "accountId": 1 + } +} +``` + +**预期结果**: +- 收到订阅确认消息: +```json +{ + "type": 4, + "channel": "order", + "status": 0, + "message": null +} +``` + +#### 2.3 等待订单消息 +当账户有订单活动时(下单、更新、取消),应该收到推送消息: +```json +{ + "type": 3, + "channel": "order", + "payload": { + "accountId": 1, + "accountName": "测试账户", + "order": { + "assetId": "...", + "eventType": "order", + "id": "...", + "market": "...", + "type": "PLACEMENT", + "side": "BUY", + "price": "0.5", + "originalSize": "10", + "sizeMatched": "0", + ... + }, + "timestamp": 1234567890 + }, + "timestamp": 1234567890 +} +``` + +### 测试 3: 测试订单类型 + +#### 3.1 PLACEMENT (下单) +- 在 Polymarket 上创建一个订单 +- 应该收到 `type: "PLACEMENT"` 的订单消息 + +#### 3.2 UPDATE (订单更新) +- 订单部分成交时 +- 应该收到 `type: "UPDATE"` 的订单消息,`sizeMatched` 字段会更新 + +#### 3.3 CANCELLATION (订单取消) +- 取消一个订单 +- 应该收到 `type: "CANCELLATION"` 的订单消息 + +### 测试 4: 多账户支持 +1. 导入多个账户(都有 API 凭证) +2. 订阅不同账户的订单推送 +3. 验证每个账户的订单消息都能正确推送 + +### 测试 5: 连接重连 +1. 断开网络连接 +2. 恢复网络连接 +3. 验证连接是否自动重连(需要实现重连逻辑) + +### 测试 6: 代理支持 +如果配置了代理: +```bash +export ENABLE_PROXY=1 +export PROXY_HOST=127.0.0.1 +export PROXY_PORT=8888 +``` + +启动服务后,应该看到日志: +- `已配置 WebSocket 代理: 127.0.0.1:8888` + +## 常见问题排查 + +### 问题 1: 连接建立失败 +**可能原因**: +- Polymarket RTDS 服务不可用 +- 网络连接问题 +- 代理配置错误 + +**排查步骤**: +1. 检查 `polymarket.rtds.ws-url` 配置是否正确 +2. 检查网络连接 +3. 查看详细错误日志 + +### 问题 2: 订阅消息发送失败 +**可能原因**: +- 连接未完全建立就发送消息 +- API 凭证无效 + +**排查步骤**: +1. 检查日志中的连接状态 +2. 验证 API 凭证是否正确 +3. 增加连接建立的等待时间 + +### 问题 3: 收不到订单消息 +**可能原因**: +- 账户没有订单活动 +- 订阅消息格式错误 +- API 凭证权限不足 + +**排查步骤**: +1. 在 Polymarket 上手动创建一个订单 +2. 检查订阅消息格式是否正确 +3. 验证 API Key 是否有订单查询权限 + +### 问题 4: 账户 ID 为 null +**可能原因**: +- 账户未正确保存到数据库 +- 账户 ID 未正确传递 + +**排查步骤**: +1. 检查数据库中的账户记录 +2. 验证订阅消息中的 accountId 是否正确 + +## 日志关键字 + +搜索以下关键字查看相关日志: +- `订单推送服务已初始化` +- `已为账户 X 建立 User Channel 连接` +- `已发送 User Channel 订阅消息` +- `处理订单消息失败` +- `推送订单消息失败` + +## 下一步 + +测试通过后,可以: +1. 实现前端订阅和 Notification 显示 +2. 添加连接重连逻辑 +3. 优化错误处理 +4. 添加单元测试 + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt index bc52b0b..70eda9d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -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 + ): Response /** * 获取活跃订单 @@ -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? = 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 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt new file mode 100644 index 0000000..fa87527 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt @@ -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? = null, + @Query("include_tag") includeTag: Boolean? = null + ): Response> +} + +/** + * 市场响应(根据 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 +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index 85150d5..aac0392 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -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 是否已配置 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt new file mode 100644 index 0000000..5d6d869 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderMessageDto.kt @@ -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?, // 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 // 市场图标 +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt index b697703..7205d33 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index 0bf2ded..e713a2c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -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, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt new file mode 100644 index 0000000..bae6a76 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderPushService.kt @@ -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() + + // 存储账户 ID 和对应的推送回调:accountId -> Set<(OrderPushMessage) -> Unit> + private val accountCallbacks = ConcurrentHashMap 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(), // 空数组表示订阅所有市场 + "assets_ids" to emptyList(), + "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) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt index 8ded3f2..9e2b4b4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt @@ -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 { + 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) + } + } + /** * 获取活跃订单 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/WebSocketSubscriptionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/WebSocketSubscriptionService.kt index 76d63c1..6018897 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/WebSocketSubscriptionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/WebSocketSubscriptionService.kt @@ -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 private val channelSubscriptions = ConcurrentHashMap>() + // 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅) + private val orderChannelCallbacks = ConcurrentHashMap 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") + } + } } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt index d1a8934..be7b3af 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt @@ -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 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt index 1a08280..4e2ab3b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt @@ -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) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 650447c..5367458 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -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 + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt index 3ce9059..72f6f4f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt @@ -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 } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt index 9922986..55065de 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt @@ -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 diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 74a0818..ae03346 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 查询持仓 diff --git a/backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql b/backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql new file mode 100644 index 0000000..d4553ff --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__add_is_enabled_to_accounts.sql @@ -0,0 +1,4 @@ +-- 添加是否启用字段到账户表 +ALTER TABLE copy_trading_accounts +ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)' AFTER is_default; + diff --git a/docs/position-sell-order-design.md b/docs/position-sell-order-design.md new file mode 100644 index 0000000..89fe679 --- /dev/null +++ b/docs/position-sell-order-design.md @@ -0,0 +1,357 @@ +# 仓位出售订单功能设计文档 + +## 1. 功能概述 + +为仓位管理页面添加出售功能,支持用户对当前仓位进行市价或限价卖出操作。 + +## 2. 后端接口设计 + +### 2.1 创建卖出订单接口 + +**接口路径**: `POST /api/copy-trading/positions/sell` + +**请求体**: +```kotlin +data class PositionSellRequest( + val accountId: Long, // 账户ID(必需) + val marketId: String, // 市场ID(必需) + val side: String, // 方向:YES 或 NO(必需) + val orderType: String, // 订单类型:MARKET(市价)或 LIMIT(限价)(必需) + val quantity: String, // 卖出数量(必需,BigDecimal字符串) + val price: String? = null // 限价价格(限价订单必需,市价订单不需要) +) +``` + +**响应体**: +```kotlin +data class PositionSellResponse( + val orderId: String, // 订单ID + val marketId: String, // 市场ID + val side: String, // 方向 + val orderType: String, // 订单类型 + val quantity: String, // 订单数量 + val price: String?, // 订单价格(限价订单) + val status: String, // 订单状态 + val createdAt: Long // 创建时间戳 +) +``` + +**业务逻辑**: +1. 验证账户是否存在且已配置API凭证 +2. 验证仓位是否存在且数量足够 +3. 验证订单参数(数量、价格等) +4. 市价订单:获取当前最优卖价(bestBid)作为价格 +5. 限价订单:验证价格是否合理 +6. 调用Polymarket CLOB API创建订单 +7. 返回订单信息 + +**错误处理**: +- 账户不存在或未配置API凭证:返回错误码 2001 +- 仓位不存在或数量不足:返回错误码 4001 +- 价格或数量格式错误:返回错误码 1001 +- API调用失败:返回错误码 5001 + +### 2.2 获取市场当前价格接口(可选,用于显示参考价格) + +**接口路径**: `POST /api/copy-trading/markets/price` + +**请求体**: +```kotlin +data class MarketPriceRequest( + val marketId: String // 市场ID +) +``` + +**响应体**: +```kotlin +data class MarketPriceResponse( + val marketId: String, + val lastPrice: String?, // 最新成交价 + val bestBid: String?, // 最优买价(用于卖出参考) + val bestAsk: String?, // 最优卖价(用于买入参考) + val midpoint: String? // 中间价 +) +``` + +## 3. 前端交互设计 + +### 3.1 UI组件设计 + +#### 3.1.1 出售按钮 +- **位置**: 每个仓位卡片/列表项的操作区域 +- **样式**: + - 卡片视图:卡片底部或操作区域 + - 列表视图:操作列 +- **显示条件**: 仅当前仓位显示(历史仓位不显示) +- **按钮文本**: "卖出" 或 "出售" + +#### 3.1.2 出售模态框 + +**布局结构**: +``` +┌─────────────────────────────────┐ +│ 出售仓位 - [市场标题] │ +├─────────────────────────────────┤ +│ 账户: [账户名称] │ +│ 方向: [YES/NO标签] │ +│ 当前持仓: [数量] │ +│ 平均价格: [平均买入价格] │ +│ 当前价格: [当前市场价格] │ +├─────────────────────────────────┤ +│ 订单类型: │ +│ ○ 市价出售 ○ 限价出售 │ +├─────────────────────────────────┤ +│ 卖出数量: │ +│ [输入框] │ +│ [20%] [50%] [80%] [100%] │ +├─────────────────────────────────┤ +│ 限价价格: (限价时显示) │ +│ [输入框] │ +│ 参考价格: [当前最优买价] │ +├─────────────────────────────────┤ +│ 预计平仓收益: │ +│ 收益金额: [+/-XXX.XX USDC] │ +│ 收益率: [+/-XX.XX%] │ +│ (实时计算,根据数量和价格更新) │ +├─────────────────────────────────┤ +│ [取消] [确认卖出] │ +└─────────────────────────────────┘ +``` + +**字段说明**: +1. **订单类型选择**: + - 单选按钮:市价出售 / 限价出售 + - 默认:限价出售 + - 切换时显示/隐藏限价输入框 + +2. **卖出数量**: + - 输入框:支持手动输入 + - 快捷按钮:20%, 50%, 80%, 100% + - 点击快捷按钮自动填充到输入框 + - 验证:不能超过当前持仓数量,不能为0 + +3. **限价价格**(限价订单时显示): + - 输入框:支持手动输入 + - 显示参考价格:当前最优买价(bestBid) + - 验证:价格必须大于0 + +4. **按钮**: + - 取消:关闭模态框 + - 确认卖出:提交订单(加载状态) + +### 3.2 交互流程 + +1. **打开模态框**: + - 点击"卖出"按钮 + - 加载市场当前价格(用于显示参考价格) + - 初始化表单(默认限价,数量为空) + +2. **选择订单类型**: + - 切换市价/限价 + - 市价:隐藏限价输入框 + - 限价:显示限价输入框和参考价格 + +3. **设置数量**: + - 点击快捷按钮(20%, 50%, 80%, 100%) + - 自动计算并填充到输入框 + - 实时验证数量是否有效 + +4. **设置限价**(限价订单): + - 手动输入价格 + - 显示参考价格提示 + - 实时更新平仓收益 + +5. **查看平仓收益**(实时计算): + - 根据卖出数量和价格实时计算 + - 计算公式: + - 收益金额 = (卖出价格 - 平均买入价格) × 卖出数量 + - 收益率 = (卖出价格 - 平均买入价格) / 平均买入价格 × 100% + - 市价订单:使用当前最优买价计算 + - 限价订单:使用输入的限价计算 + - 颜色显示:盈利为绿色,亏损为红色 + +6. **提交订单**: + - 点击"确认卖出" + - 显示加载状态 + - 调用后端接口 + - 成功:显示成功提示,关闭模态框,刷新仓位列表 + - 失败:显示错误提示 + +### 3.3 数据验证 + +**前端验证**: +- 数量:必填,大于0,不超过当前持仓数量 +- 限价价格:限价订单必填,大于0 +- 账户:必须已配置API凭证 + +**后端验证**: +- 账户存在且已配置API凭证 +- 仓位存在且数量足够 +- 价格和数量格式正确 +- 市价订单自动获取最优价格 + +## 4. 类型定义 + +### 4.1 前端TypeScript类型 + +```typescript +/** + * 仓位卖出请求 + */ +export interface PositionSellRequest { + accountId: number + marketId: string + side: 'YES' | 'NO' + orderType: 'MARKET' | 'LIMIT' + quantity: string + price?: string // 限价订单必需 +} + +/** + * 仓位卖出响应 + */ +export interface PositionSellResponse { + orderId: string + marketId: string + side: string + orderType: string + quantity: string + price?: string + status: string + createdAt: number +} + +/** + * 市场价格请求 + */ +export interface MarketPriceRequest { + marketId: string +} + +/** + * 市场价格响应 + */ +export interface MarketPriceResponse { + marketId: string + lastPrice?: string + bestBid?: string + bestAsk?: string + midpoint?: string +} +``` + +## 5. API服务方法 + +### 5.1 前端API服务 + +```typescript +// frontend/src/services/api.ts +export const apiService = { + positions: { + /** + * 卖出仓位 + */ + sell: (data: PositionSellRequest) => + apiClient.post>('/copy-trading/positions/sell', data), + + /** + * 获取市场价格 + */ + getMarketPrice: (data: MarketPriceRequest) => + apiClient.post>('/copy-trading/markets/price', data) + } +} +``` + +### 5.2 后端Controller方法 + +```kotlin +@PostMapping("/positions/sell") +suspend fun sellPosition(@RequestBody request: PositionSellRequest): ResponseEntity> + +@PostMapping("/markets/price") +suspend fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity> +``` + +## 6. 实现细节 + +### 6.1 市价订单处理 + +- 市价订单需要获取当前最优买价(bestBid)作为卖出价格 +- 如果无法获取最优买价,使用最新成交价(lastPrice) +- 如果都没有,返回错误提示 + +### 6.2 数量计算 + +- 快捷按钮计算:`数量 = 当前持仓数量 × 百分比` +- 保留4位小数(与仓位数量精度一致) +- 验证:不能超过当前持仓数量 + +### 6.3 平仓收益实时计算 + +**计算逻辑**: +```typescript +// 获取仓位信息 +const avgPrice = parseFloat(position.avgPrice) // 平均买入价格 +const quantity = parseFloat(sellQuantity) // 卖出数量 +const sellPrice = orderType === 'MARKET' + ? parseFloat(marketPrice.bestBid) // 市价:使用最优买价 + : parseFloat(limitPrice) // 限价:使用输入价格 + +// 计算收益 +const pnl = (sellPrice - avgPrice) * quantity +const percentPnl = ((sellPrice - avgPrice) / avgPrice) * 100 + +// 显示格式 +const pnlDisplay = `${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} USDC` +const percentPnlDisplay = `${percentPnl >= 0 ? '+' : ''}${percentPnl.toFixed(2)}%` +``` + +**更新时机**: +- 数量输入框值变化时 +- 限价价格输入框值变化时(限价订单) +- 订单类型切换时(市价/限价) +- 市场价格更新时(市价订单,如果支持实时更新) + +**显示样式**: +- 盈利:绿色文字(#52c41a) +- 亏损:红色文字(#f5222d) +- 字体:加粗显示,突出重要性 + +### 6.4 错误处理 + +- 网络错误:显示"网络错误,请重试" +- API错误:显示后端返回的错误信息 +- 验证错误:显示具体的验证失败原因 + +### 6.5 用户体验优化 + +- 提交订单时禁用按钮,显示加载状态 +- 成功后自动刷新仓位列表 +- 提供清晰的成功/失败提示 +- 模态框支持ESC键关闭 + +## 7. 安全考虑 + +1. **权限验证**: 验证账户是否属于当前用户 +2. **数量验证**: 确保卖出数量不超过持仓数量 +3. **价格验证**: 限价订单验证价格合理性 +4. **API凭证**: 确保账户已配置有效的API凭证 + +## 8. 测试要点 + +1. 市价订单创建成功 +2. 限价订单创建成功 +3. 数量快捷按钮功能 +4. 数量验证(超过持仓、为0等) +5. 价格验证(限价订单) +6. **平仓收益实时计算**: + - 数量变化时收益更新 + - 限价变化时收益更新 + - 订单类型切换时收益更新 + - 收益金额和收益率计算正确 + - 盈利/亏损颜色显示正确 +7. 账户未配置API凭证的错误处理 +8. 仓位不存在的错误处理 +9. 网络错误处理 + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d37c5a..ab8d9bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ -import { useEffect } from 'react' +import { useEffect, useCallback } from 'react' import { BrowserRouter, Routes, Route } from 'react-router-dom' -import { ConfigProvider } from 'antd' +import { ConfigProvider, notification } from 'antd' import zhCN from 'antd/locale/zh_CN' import Layout from './components/Layout' import AccountList from './pages/AccountList' @@ -13,8 +13,83 @@ import ConfigPage from './pages/ConfigPage' import PositionList from './pages/PositionList' import Statistics from './pages/Statistics' import { wsManager } from './services/websocket' +import type { OrderPushMessage } from './types' function App() { + /** + * 获取订单类型文本 + */ + const getOrderTypeText = useCallback((type: string): string => { + switch (type) { + case 'PLACEMENT': + return '订单创建' + case 'UPDATE': + return '订单更新' + case 'CANCELLATION': + return '订单取消' + default: + return '订单事件' + } + }, []) + + /** + * 处理订单推送消息,显示全局通知 + */ + const handleOrderPush = useCallback((message: OrderPushMessage) => { + const { accountName, order, orderDetail } = message + + // 根据订单类型和操作类型确定通知内容 + const orderTypeText = getOrderTypeText(order.type) + const sideText = order.side === 'BUY' ? '买入' : '卖出' + + // 如果有市场名称,在标题中显示 + const marketName = orderDetail?.marketName || order.market.substring(0, 8) + '...' + const title = `${accountName} - ${orderTypeText}` + + // 优先使用订单详情中的数据,如果没有则使用 WebSocket 消息中的数据 + const price = orderDetail ? parseFloat(orderDetail.price).toFixed(4) : parseFloat(order.price).toFixed(4) + const size = orderDetail ? parseFloat(orderDetail.size).toFixed(2) : parseFloat(order.original_size).toFixed(2) + const filled = orderDetail ? parseFloat(orderDetail.filled).toFixed(2) : parseFloat(order.size_matched).toFixed(2) + const status = orderDetail?.status || 'UNKNOWN' + + // 构建描述信息 + let description = `市场: ${marketName}\n${sideText} ${size} @ ${price}` + + // 如果有订单详情,显示更详细的信息 + if (orderDetail) { + description += `\n状态: ${status}` + if (parseFloat(filled) > 0) { + description += ` | 已成交: ${filled}` + } + const remaining = (parseFloat(size) - parseFloat(filled)).toFixed(2) + if (parseFloat(remaining) > 0) { + description += ` | 剩余: ${remaining}` + } + } else if (order.type === 'UPDATE' && parseFloat(order.size_matched) > 0) { + // 如果没有订单详情,使用 WebSocket 消息中的已成交数量 + description += `\n已成交: ${filled}` + } + + // 根据订单类型选择通知类型 + let notificationType: 'info' | 'success' | 'warning' | 'error' = 'info' + if (order.type === 'PLACEMENT') { + notificationType = 'info' + } else if (order.type === 'UPDATE') { + notificationType = 'success' + } else if (order.type === 'CANCELLATION') { + notificationType = 'warning' + } + + // 显示通知 + notification[notificationType]({ + message: title, + description: description, + placement: 'topRight', + duration: order.type === 'CANCELLATION' ? 3 : 5, // 取消订单通知显示时间短一些 + key: `order-${order.id}`, // 使用订单 ID 作为 key,避免重复通知 + }) + }, [getOrderTypeText]) + // 应用启动时立即建立全局 WebSocket 连接 useEffect(() => { // 立即建立连接(如果还未连接) @@ -26,6 +101,17 @@ function App() { // WebSocket 连接会在整个应用生命周期中保持,并自动重连 }, []) + // 订阅订单推送并显示全局通知 + useEffect(() => { + const unsubscribe = wsManager.subscribe('order', (data: OrderPushMessage) => { + handleOrderPush(data) + }) + + return () => { + unsubscribe() + } + }, [handleOrderPush]) + return ( diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 85dbe87..125758c 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,12 +1,12 @@ import { useEffect, useState, useRef } from 'react' -import { wsManager, SubscriptionCallback } from '../services/websocket' +import { wsManager } from '../services/websocket' /** * 使用 WebSocket 订阅 */ export function useWebSocketSubscription( channel: string, - callback: SubscriptionCallback, + callback: (data: T) => void, payload?: any ): { connected: boolean } { const [connected, setConnected] = useState(wsManager.isConnected()) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b9c3afa..9131b66 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -210,3 +210,51 @@ export function getPositionKey(position: AccountPosition): string { return `${position.accountId}-${position.marketId}-${position.side}` } +/** + * Polymarket 订单消息(来自 WebSocket User Channel) + */ +export interface OrderMessage { + asset_id: string + associate_trades?: string[] + event_type: string // "order" + id: string // order id + market: string // condition ID of market + order_owner: string // owner of order + original_size: string // original order size + outcome: string // outcome + owner: string // owner of orders + price: string // price of order + side: string // BUY/SELL + size_matched: string // size of order that has been matched + timestamp: string // time of event + type: string // PLACEMENT/UPDATE/CANCELLATION +} + +/** + * 订单详情(通过 API 获取) + */ +export interface OrderDetail { + id: string // 订单 ID + market: string // 市场 ID (condition ID) + side: string // BUY/SELL + price: string // 价格 + size: string // 订单大小 + filled: string // 已成交数量 + status: string // 订单状态 + createdAt: string // 创建时间(ISO 8601 格式) + marketName?: string // 市场名称 + marketSlug?: string // 市场 slug + marketIcon?: string // 市场图标 +} + +/** + * 订单推送消息 + */ +export interface OrderPushMessage { + accountId: number + accountName: string + order: OrderMessage // 订单信息(来自 WebSocket) + orderDetail?: OrderDetail // 订单详情(通过 API 获取) + timestamp?: number // 推送时间戳 +} +