47357995af
- 后端重构: - 统一 WebSocket 路径为 /ws,通过 channel 区分不同推送服务 - 实现 UnifiedWebSocketHandler 统一处理所有推送频道 - 实现 WebSocketSubscriptionService 管理订阅和推送 - 消息类型改为 int 类型(1:SUB, 2:UNSUB, 3:DATA, 4:SUB_ACK, 5:PING, 6:PONG) - status 字段改为 int 类型(0: success, 非0: error) - 移除旧的 /ws/positions 路由和 PositionWebSocketHandler - 修复首推数据:订阅 position 频道后立即发送全量数据 - 前端重构: - 实现全局 WebSocket 管理器(单例模式) - 应用启动时立即建立全局 WebSocket 连接 - 实现 useWebSocketSubscription hook 用于订阅频道 - PositionList 完全依赖 WebSocket 推送,移除 HTTP 轮询 - 添加连接状态显示和自动重连机制 - 实现心跳保活机制(PING/PONG) - 配置更新: - 添加 WebSocket 相关配置项 - 更新 vite.config.ts 添加 /ws 代理配置
1077 lines
28 KiB
Markdown
1077 lines
28 KiB
Markdown
# WebSocket 推送服务重构设计文档
|
||
|
||
## 1. 概述
|
||
|
||
### 1.1 设计目标
|
||
|
||
- **统一连接**:所有推送服务共用一个 WebSocket 连接(`/ws`)
|
||
- **多频道支持**:支持多个推送频道(position、order 等)
|
||
- **订阅管理**:前端统一管理订阅,避免重复订阅
|
||
- **数据分发**:后端根据频道推送,前端分发到订阅者
|
||
- **可扩展性**:易于添加新的推送频道
|
||
|
||
### 1.2 架构设计
|
||
|
||
```
|
||
前端应用
|
||
↓
|
||
全局 WebSocket 连接 (/ws)
|
||
↓
|
||
订阅管理器 (统一管理订阅)
|
||
↓
|
||
频道分发器 (根据 channel 分发数据)
|
||
↓
|
||
订阅者 (组件/页面)
|
||
```
|
||
|
||
## 2. 消息协议
|
||
|
||
### 2.1 客户端 → 服务端
|
||
|
||
#### 订阅频道
|
||
```json
|
||
{
|
||
"type": 1,
|
||
"channel": "position",
|
||
"payload": {} // 可选,根据频道自定义参数
|
||
}
|
||
```
|
||
|
||
#### 取消订阅
|
||
```json
|
||
{
|
||
"type": 2,
|
||
"channel": "position"
|
||
}
|
||
```
|
||
|
||
#### 心跳保活
|
||
```json
|
||
"PING"
|
||
```
|
||
|
||
### 2.2 服务端 → 客户端
|
||
|
||
#### 数据推送
|
||
```json
|
||
{
|
||
"type": 3,
|
||
"channel": "position",
|
||
"payload": {
|
||
// 频道数据,根据频道类型定义
|
||
},
|
||
"timestamp": 1234567890
|
||
}
|
||
```
|
||
|
||
#### 订阅确认
|
||
```json
|
||
{
|
||
"type": 4,
|
||
"channel": "position",
|
||
"status": 0, // 0: success, 非0: error
|
||
"message": "" // 错误时提供错误信息
|
||
}
|
||
```
|
||
|
||
#### 心跳响应
|
||
```json
|
||
"PONG"
|
||
```
|
||
|
||
### 2.3 消息类型定义
|
||
|
||
**WebSocketMessageType 枚举值**:
|
||
- `1`: SUB - 订阅
|
||
- `2`: UNSUB - 取消订阅
|
||
- `3`: DATA - 数据推送
|
||
- `4`: SUB_ACK - 订阅确认
|
||
- `5`: PING - 心跳
|
||
- `6`: PONG - 心跳响应
|
||
|
||
**status 字段**:
|
||
- `0`: 成功
|
||
- `非0`: 错误(具体错误码可自定义)
|
||
|
||
## 3. 频道定义
|
||
|
||
### 3.1 仓位推送频道 (position)
|
||
|
||
#### 订阅参数
|
||
```json
|
||
{
|
||
"type": "sub",
|
||
"channel": "position",
|
||
"payload": {} // 当前无需参数,后续可扩展(如账户筛选等)
|
||
}
|
||
```
|
||
|
||
#### 推送数据格式
|
||
```json
|
||
{
|
||
"type": "data",
|
||
"channel": "position",
|
||
"payload": {
|
||
"messageType": "FULL", // FULL | INCREMENTAL
|
||
"currentPositions": [],
|
||
"historyPositions": [],
|
||
"removedPositionKeys": []
|
||
},
|
||
"timestamp": 1234567890
|
||
}
|
||
```
|
||
|
||
### 3.2 订单推送频道 (order)
|
||
|
||
#### 订阅参数
|
||
```json
|
||
{
|
||
"type": "sub",
|
||
"channel": "order",
|
||
"payload": {
|
||
"accountId": 123, // 可选,筛选特定账户
|
||
"status": "active" // 可选,筛选订单状态
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 推送数据格式
|
||
```json
|
||
{
|
||
"type": "data",
|
||
"channel": "order",
|
||
"payload": {
|
||
"messageType": "FULL", // FULL | INCREMENTAL
|
||
"orders": [],
|
||
"removedOrderIds": []
|
||
},
|
||
"timestamp": 1234567890
|
||
}
|
||
```
|
||
|
||
## 4. 前端实现方案
|
||
|
||
### 4.1 全局 WebSocket 管理器
|
||
|
||
**文件**: `frontend/src/services/websocket.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* WebSocket 消息类型(int 值)
|
||
*/
|
||
export enum WebSocketMessageType {
|
||
SUB = 1, // 订阅
|
||
UNSUB = 2, // 取消订阅
|
||
DATA = 3, // 数据推送
|
||
SUB_ACK = 4, // 订阅确认
|
||
PING = 5, // 心跳
|
||
PONG = 6 // 心跳响应
|
||
}
|
||
|
||
/**
|
||
* WebSocket 消息
|
||
*/
|
||
export interface WebSocketMessage {
|
||
type: number // WebSocketMessageType 的 int 值(1:SUB, 2:UNSUB, 3:DATA, 4:SUB_ACK, 5:PING, 6:PONG)
|
||
channel?: string
|
||
payload?: any
|
||
timestamp?: number
|
||
status?: number // 0: success, 非0: error
|
||
message?: string
|
||
}
|
||
|
||
/**
|
||
* 订阅回调函数
|
||
*/
|
||
export type SubscriptionCallback = (data: any) => void
|
||
|
||
/**
|
||
* 全局 WebSocket 管理器
|
||
*/
|
||
class WebSocketManager {
|
||
private ws: WebSocket | null = null
|
||
private reconnectTimer: NodeJS.Timeout | null = null
|
||
private pingInterval: NodeJS.Timeout | null = null
|
||
private isConnecting = false
|
||
private isUnmounting = false
|
||
|
||
// 订阅管理:channel -> Set<callback>
|
||
private subscriptions = new Map<string, Set<SubscriptionCallback>>()
|
||
|
||
// 订阅状态:channel -> boolean(是否已向后端订阅)
|
||
private subscribedChannels = new Set<string>()
|
||
|
||
// 连接状态回调
|
||
private connectionCallbacks: Set<(connected: boolean) => void> = new Set()
|
||
|
||
private reconnectDelay = 3000
|
||
private pingIntervalTime = 30000
|
||
|
||
/**
|
||
* 连接 WebSocket
|
||
*/
|
||
connect(): void {
|
||
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
||
return
|
||
}
|
||
|
||
if (this.isUnmounting) {
|
||
return
|
||
}
|
||
|
||
this.isConnecting = true
|
||
const wsUrl = this.getWebSocketUrl()
|
||
console.log('正在连接 WebSocket:', wsUrl)
|
||
|
||
try {
|
||
const ws = new WebSocket(wsUrl)
|
||
this.ws = ws
|
||
|
||
ws.onopen = () => {
|
||
console.log('WebSocket 连接成功')
|
||
this.isConnecting = false
|
||
this.notifyConnectionStatus(true)
|
||
this.startPing()
|
||
this.resubscribeAll() // 重新订阅所有频道
|
||
}
|
||
|
||
ws.onmessage = (event) => {
|
||
this.handleMessage(event.data)
|
||
}
|
||
|
||
ws.onerror = (error) => {
|
||
console.error('WebSocket 错误:', error)
|
||
this.isConnecting = false
|
||
this.notifyConnectionStatus(false)
|
||
}
|
||
|
||
ws.onclose = () => {
|
||
console.log('WebSocket 连接关闭')
|
||
this.isConnecting = false
|
||
this.notifyConnectionStatus(false)
|
||
this.stopPing()
|
||
this.scheduleReconnect()
|
||
}
|
||
} catch (error) {
|
||
console.error('创建 WebSocket 连接失败:', error)
|
||
this.isConnecting = false
|
||
this.notifyConnectionStatus(false)
|
||
this.scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 断开连接
|
||
*/
|
||
disconnect(): void {
|
||
this.isUnmounting = true
|
||
this.stopPing()
|
||
if (this.reconnectTimer) {
|
||
clearTimeout(this.reconnectTimer)
|
||
this.reconnectTimer = null
|
||
}
|
||
if (this.ws) {
|
||
this.ws.close()
|
||
this.ws = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 订阅频道
|
||
*/
|
||
subscribe(channel: string, callback: SubscriptionCallback, payload?: any): () => void {
|
||
// 添加订阅者
|
||
if (!this.subscriptions.has(channel)) {
|
||
this.subscriptions.set(channel, new Set())
|
||
}
|
||
this.subscriptions.get(channel)!.add(callback)
|
||
|
||
// 如果还未向后端订阅,发送订阅消息
|
||
if (!this.subscribedChannels.has(channel)) {
|
||
this.sendSubscribe(channel, payload)
|
||
}
|
||
|
||
// 返回取消订阅函数
|
||
return () => {
|
||
this.unsubscribe(channel, callback)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消订阅
|
||
*/
|
||
unsubscribe(channel: string, callback: SubscriptionCallback): void {
|
||
const callbacks = this.subscriptions.get(channel)
|
||
if (callbacks) {
|
||
callbacks.delete(callback)
|
||
|
||
// 如果没有订阅者了,向后端取消订阅
|
||
if (callbacks.size === 0) {
|
||
this.subscriptions.delete(channel)
|
||
this.sendUnsubscribe(channel)
|
||
this.subscribedChannels.delete(channel)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送订阅消息
|
||
*/
|
||
private sendSubscribe(channel: string, payload?: any): void {
|
||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||
const message: WebSocketMessage = {
|
||
type: 'sub',
|
||
channel,
|
||
payload
|
||
}
|
||
this.ws.send(JSON.stringify(message))
|
||
this.subscribedChannels.add(channel)
|
||
console.log('已订阅频道:', channel)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送取消订阅消息
|
||
*/
|
||
private sendUnsubscribe(channel: string): void {
|
||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||
const message: WebSocketMessage = {
|
||
type: 'unsub',
|
||
channel
|
||
}
|
||
this.ws.send(JSON.stringify(message))
|
||
console.log('已取消订阅频道:', channel)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理收到的消息
|
||
*/
|
||
private handleMessage(data: string): void {
|
||
// 处理心跳
|
||
if (data === 'PONG') {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const message: WebSocketMessage = JSON.parse(data)
|
||
|
||
if (message.type === WebSocketMessageType.DATA && message.channel) {
|
||
// 数据推送:分发到订阅者
|
||
const callbacks = this.subscriptions.get(message.channel)
|
||
if (callbacks) {
|
||
callbacks.forEach(callback => {
|
||
try {
|
||
callback(message.payload)
|
||
} catch (error) {
|
||
console.error(`频道 ${message.channel} 回调执行失败:`, error)
|
||
}
|
||
})
|
||
}
|
||
} else if (message.type === WebSocketMessageType.SUB_ACK) {
|
||
// 订阅确认
|
||
if (message.status !== undefined && message.status !== 0) {
|
||
console.error(`订阅频道 ${message.channel} 失败:`, message.message)
|
||
this.subscribedChannels.delete(message.channel || '')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('解析 WebSocket 消息失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重新订阅所有频道
|
||
*/
|
||
private resubscribeAll(): void {
|
||
this.subscribedChannels.clear()
|
||
this.subscriptions.forEach((callbacks, channel) => {
|
||
if (callbacks.size > 0) {
|
||
this.sendSubscribe(channel)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 安排重连
|
||
*/
|
||
private scheduleReconnect(): void {
|
||
if (this.isUnmounting) {
|
||
return
|
||
}
|
||
|
||
if (this.reconnectTimer) {
|
||
clearTimeout(this.reconnectTimer)
|
||
}
|
||
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this.connect()
|
||
}, this.reconnectDelay)
|
||
}
|
||
|
||
/**
|
||
* 开始心跳
|
||
*/
|
||
private startPing(): void {
|
||
this.stopPing()
|
||
this.pingInterval = setInterval(() => {
|
||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||
this.ws.send('PING')
|
||
}
|
||
}, this.pingIntervalTime)
|
||
}
|
||
|
||
/**
|
||
* 停止心跳
|
||
*/
|
||
private stopPing(): void {
|
||
if (this.pingInterval) {
|
||
clearInterval(this.pingInterval)
|
||
this.pingInterval = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 WebSocket URL
|
||
*/
|
||
private getWebSocketUrl(): string {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||
const host = window.location.host
|
||
return `${protocol}//${host}/ws`
|
||
}
|
||
|
||
/**
|
||
* 注册连接状态回调
|
||
*/
|
||
onConnectionChange(callback: (connected: boolean) => void): () => void {
|
||
this.connectionCallbacks.add(callback)
|
||
return () => {
|
||
this.connectionCallbacks.delete(callback)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通知连接状态变化
|
||
*/
|
||
private notifyConnectionStatus(connected: boolean): void {
|
||
this.connectionCallbacks.forEach(callback => {
|
||
try {
|
||
callback(connected)
|
||
} catch (error) {
|
||
console.error('连接状态回调执行失败:', error)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 获取连接状态
|
||
*/
|
||
isConnected(): boolean {
|
||
return this.ws?.readyState === WebSocket.OPEN
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const wsManager = new WebSocketManager()
|
||
```
|
||
|
||
### 4.2 React Hook 封装
|
||
|
||
**文件**: `frontend/src/hooks/useWebSocket.ts`
|
||
|
||
```typescript
|
||
import { useEffect, useState, useRef } from 'react'
|
||
import { wsManager, SubscriptionCallback } from '../services/websocket'
|
||
|
||
/**
|
||
* 使用 WebSocket 订阅
|
||
*/
|
||
export function useWebSocketSubscription<T = any>(
|
||
channel: string,
|
||
callback: SubscriptionCallback,
|
||
payload?: any
|
||
): { connected: boolean } {
|
||
const [connected, setConnected] = useState(wsManager.isConnected())
|
||
const callbackRef = useRef(callback)
|
||
|
||
// 更新回调引用
|
||
useEffect(() => {
|
||
callbackRef.current = callback
|
||
}, [callback])
|
||
|
||
useEffect(() => {
|
||
// 确保连接
|
||
if (!wsManager.isConnected()) {
|
||
wsManager.connect()
|
||
}
|
||
|
||
// 订阅频道
|
||
const unsubscribe = wsManager.subscribe(channel, (data) => {
|
||
callbackRef.current(data)
|
||
}, payload)
|
||
|
||
// 监听连接状态
|
||
const removeConnectionListener = wsManager.onConnectionChange(setConnected)
|
||
|
||
return () => {
|
||
unsubscribe()
|
||
removeConnectionListener()
|
||
}
|
||
}, [channel, payload])
|
||
|
||
return { connected }
|
||
}
|
||
```
|
||
|
||
### 4.3 使用示例
|
||
|
||
**文件**: `frontend/src/pages/PositionList.tsx`
|
||
|
||
```typescript
|
||
import { useWebSocketSubscription } from '../hooks/useWebSocket'
|
||
import type { PositionPushMessage } from '../types'
|
||
|
||
const PositionList: React.FC = () => {
|
||
// 订阅仓位推送
|
||
const { connected } = useWebSocketSubscription<PositionPushMessage>(
|
||
'position',
|
||
(message) => {
|
||
if (message.messageType === 'FULL') {
|
||
setCurrentPositions(message.currentPositions || [])
|
||
setHistoryPositions(message.historyPositions || [])
|
||
setLoading(false)
|
||
} else if (message.messageType === 'INCREMENTAL') {
|
||
setCurrentPositions(prev => mergePositions(prev, message.currentPositions || [], message.removedPositionKeys || []))
|
||
setHistoryPositions(prev => mergePositions(prev, message.historyPositions || [], message.removedPositionKeys || []))
|
||
}
|
||
}
|
||
)
|
||
|
||
// ... 其他代码
|
||
}
|
||
```
|
||
|
||
## 5. 后端实现方案
|
||
|
||
### 5.1 WebSocket 消息 DTO
|
||
|
||
**文件**: `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/WebSocketMessageDto.kt`
|
||
|
||
```kotlin
|
||
package com.wrbug.polymarketbot.dto
|
||
|
||
/**
|
||
* WebSocket 消息类型
|
||
*/
|
||
enum class WebSocketMessageType {
|
||
SUB, // 订阅
|
||
UNSUB, // 取消订阅
|
||
DATA, // 数据推送
|
||
SUB_ACK, // 订阅确认
|
||
PING, // 心跳
|
||
PONG // 心跳响应
|
||
}
|
||
|
||
/**
|
||
* WebSocket 消息类型
|
||
*/
|
||
enum class WebSocketMessageType(val value: Int) {
|
||
SUB(1), // 订阅
|
||
UNSUB(2), // 取消订阅
|
||
DATA(3), // 数据推送
|
||
SUB_ACK(4), // 订阅确认
|
||
PING(5), // 心跳
|
||
PONG(6); // 心跳响应
|
||
|
||
companion object {
|
||
fun fromValue(value: Int): WebSocketMessageType? {
|
||
return values().find { it.value == value }
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WebSocket 消息
|
||
*/
|
||
data class WebSocketMessage(
|
||
val type: Int, // WebSocketMessageType 的 int 值(1:SUB, 2:UNSUB, 3:DATA, 4:SUB_ACK, 5:PING, 6:PONG)
|
||
val channel: String? = null,
|
||
val payload: Any? = null,
|
||
val timestamp: Long? = null,
|
||
val status: Int? = null, // 0: success, 非0: error
|
||
val message: String? = null // 错误信息
|
||
)
|
||
|
||
/**
|
||
* 订阅请求
|
||
*/
|
||
data class SubscribeRequest(
|
||
val channel: String,
|
||
val payload: Map<String, Any>? = null
|
||
)
|
||
|
||
/**
|
||
* 取消订阅请求
|
||
*/
|
||
data class UnsubscribeRequest(
|
||
val channel: String
|
||
)
|
||
```
|
||
|
||
### 5.2 统一 WebSocket 处理器
|
||
|
||
**文件**: `backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/UnifiedWebSocketHandler.kt`
|
||
|
||
```kotlin
|
||
package com.wrbug.polymarketbot.websocket
|
||
|
||
import com.fasterxml.jackson.databind.ObjectMapper
|
||
import com.wrbug.polymarketbot.dto.*
|
||
import com.wrbug.polymarketbot.service.WebSocketSubscriptionService
|
||
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.Component
|
||
import org.springframework.web.socket.*
|
||
import java.util.concurrent.ConcurrentHashMap
|
||
|
||
/**
|
||
* 统一 WebSocket 处理器
|
||
* 处理所有推送频道的订阅和数据推送
|
||
*/
|
||
@Component
|
||
class UnifiedWebSocketHandler(
|
||
private val objectMapper: ObjectMapper,
|
||
private val subscriptionService: WebSocketSubscriptionService
|
||
) : WebSocketHandler {
|
||
|
||
private val logger = LoggerFactory.getLogger(UnifiedWebSocketHandler::class.java)
|
||
|
||
@Value("\${websocket.heartbeat-timeout:60000}")
|
||
private var heartbeatTimeout: Long = 60000
|
||
|
||
// 存储客户端会话
|
||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
||
|
||
// 存储每个连接的最后活动时间
|
||
private val lastActivityTime = ConcurrentHashMap<String, Long>()
|
||
|
||
// 协程作用域
|
||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||
private var cleanupJob: Job? = null
|
||
|
||
@PostConstruct
|
||
fun init() {
|
||
logger.info("统一 WebSocket 处理器已初始化,心跳超时: ${heartbeatTimeout}ms")
|
||
startCleanupTask()
|
||
}
|
||
|
||
@PreDestroy
|
||
fun destroy() {
|
||
logger.info("停止统一 WebSocket 处理器")
|
||
cleanupJob?.cancel()
|
||
scope.cancel()
|
||
}
|
||
|
||
override fun afterConnectionEstablished(session: WebSocketSession) {
|
||
logger.info("WebSocket 客户端连接建立: ${session.id}")
|
||
clientSessions[session.id] = session
|
||
lastActivityTime[session.id] = System.currentTimeMillis()
|
||
|
||
// 注册会话到订阅服务
|
||
subscriptionService.registerSession(session.id) { message ->
|
||
sendMessageToClient(session.id, message)
|
||
}
|
||
}
|
||
|
||
override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
|
||
val payload = message.payload.toString()
|
||
|
||
// 处理心跳
|
||
if (payload == "PING" || payload == "ping") {
|
||
lastActivityTime[session.id] = System.currentTimeMillis()
|
||
try {
|
||
session.sendMessage(TextMessage("PONG"))
|
||
} catch (e: Exception) {
|
||
logger.error("发送心跳响应失败: ${session.id}, ${e.message}", e)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 更新活动时间
|
||
lastActivityTime[session.id] = System.currentTimeMillis()
|
||
|
||
// 解析消息
|
||
try {
|
||
val wsMessage = objectMapper.readValue(payload, WebSocketMessage::class.java)
|
||
handleWebSocketMessage(session.id, wsMessage)
|
||
} catch (e: Exception) {
|
||
logger.error("解析 WebSocket 消息失败: ${session.id}, ${e.message}", e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理 WebSocket 消息
|
||
*/
|
||
private fun handleWebSocketMessage(sessionId: String, message: WebSocketMessage) {
|
||
val messageType = WebSocketMessageType.fromValue(message.type)
|
||
when (messageType) {
|
||
WebSocketMessageType.SUB -> {
|
||
val channel = message.channel ?: return
|
||
val payload = message.payload as? Map<*, *>
|
||
subscriptionService.subscribe(sessionId, channel, payload)
|
||
}
|
||
WebSocketMessageType.UNSUB -> {
|
||
val channel = message.channel ?: return
|
||
subscriptionService.unsubscribe(sessionId, channel)
|
||
}
|
||
null -> {
|
||
logger.warn("未知的消息类型: ${message.type}")
|
||
}
|
||
else -> {
|
||
logger.warn("不支持的消息类型: $messageType")
|
||
}
|
||
}
|
||
}
|
||
|
||
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
|
||
logger.error("WebSocket 传输错误: ${session.id}, ${exception.message}", exception)
|
||
cleanup(session.id)
|
||
}
|
||
|
||
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
|
||
logger.info("WebSocket 客户端连接关闭: ${session.id}, 状态: $closeStatus")
|
||
cleanup(session.id)
|
||
}
|
||
|
||
override fun supportsPartialMessages(): Boolean = false
|
||
|
||
/**
|
||
* 发送消息给客户端
|
||
*/
|
||
private fun sendMessageToClient(sessionId: String, message: WebSocketMessage) {
|
||
val session = clientSessions[sessionId]
|
||
if (session != null && session.isOpen) {
|
||
try {
|
||
val json = objectMapper.writeValueAsString(message)
|
||
session.sendMessage(TextMessage(json))
|
||
lastActivityTime[sessionId] = System.currentTimeMillis()
|
||
} catch (e: Exception) {
|
||
logger.error("发送消息失败: $sessionId, ${e.message}", e)
|
||
cleanup(sessionId)
|
||
}
|
||
} else {
|
||
logger.warn("客户端会话不存在或已关闭: $sessionId")
|
||
cleanup(sessionId)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理资源
|
||
*/
|
||
private fun cleanup(sessionId: String) {
|
||
try {
|
||
clientSessions.remove(sessionId)
|
||
lastActivityTime.remove(sessionId)
|
||
subscriptionService.unregisterSession(sessionId)
|
||
|
||
val session = clientSessions[sessionId]
|
||
if (session != null && session.isOpen) {
|
||
try {
|
||
session.close(CloseStatus.NORMAL)
|
||
} catch (e: Exception) {
|
||
logger.debug("关闭会话失败: $sessionId, ${e.message}")
|
||
}
|
||
}
|
||
|
||
logger.info("已清理 WebSocket 资源: $sessionId")
|
||
} catch (e: Exception) {
|
||
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启动清理任务
|
||
*/
|
||
private fun startCleanupTask() {
|
||
cleanupJob = scope.launch {
|
||
while (isActive) {
|
||
try {
|
||
cleanupInactiveConnections()
|
||
} catch (e: Exception) {
|
||
logger.error("清理不活跃连接失败: ${e.message}", e)
|
||
}
|
||
delay(30000)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理不活跃的连接
|
||
*/
|
||
private fun cleanupInactiveConnections() {
|
||
val now = System.currentTimeMillis()
|
||
val inactiveSessions = mutableListOf<String>()
|
||
|
||
lastActivityTime.forEach { (sessionId, lastActivity) ->
|
||
val inactiveTime = now - lastActivity
|
||
if (inactiveTime > heartbeatTimeout) {
|
||
inactiveSessions.add(sessionId)
|
||
}
|
||
}
|
||
|
||
inactiveSessions.forEach { sessionId ->
|
||
logger.warn("检测到不活跃连接,准备清理: $sessionId, 不活跃时间: ${now - (lastActivityTime[sessionId] ?: 0)}ms")
|
||
cleanup(sessionId)
|
||
}
|
||
|
||
if (inactiveSessions.isNotEmpty()) {
|
||
logger.info("已清理 ${inactiveSessions.size} 个不活跃连接")
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.3 订阅管理服务
|
||
|
||
**文件**: `backend/src/main/kotlin/com/wrbug/polymarketbot/service/WebSocketSubscriptionService.kt`
|
||
|
||
```kotlin
|
||
package com.wrbug.polymarketbot.service
|
||
|
||
import com.wrbug.polymarketbot.dto.WebSocketMessage
|
||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||
import org.slf4j.LoggerFactory
|
||
import org.springframework.stereotype.Service
|
||
import java.util.concurrent.ConcurrentHashMap
|
||
|
||
/**
|
||
* WebSocket 订阅管理服务
|
||
* 管理所有频道的订阅和数据推送
|
||
*/
|
||
@Service
|
||
class WebSocketSubscriptionService(
|
||
private val positionPushService: PositionPushService,
|
||
private val orderPushService: OrderPushService // 后续实现
|
||
) {
|
||
|
||
private val logger = LoggerFactory.getLogger(WebSocketSubscriptionService::class.java)
|
||
|
||
// 存储会话和对应的推送回调
|
||
private val sessionCallbacks = ConcurrentHashMap<String, (WebSocketMessage) -> Unit>()
|
||
|
||
// 存储每个会话的订阅频道:sessionId -> Set<channel>
|
||
private val sessionSubscriptions = ConcurrentHashMap<String, MutableSet<String>>()
|
||
|
||
// 存储每个频道的订阅会话数:channel -> Set<sessionId>
|
||
private val channelSubscriptions = ConcurrentHashMap<String, MutableSet<String>>()
|
||
|
||
/**
|
||
* 注册会话
|
||
*/
|
||
fun registerSession(sessionId: String, callback: (WebSocketMessage) -> Unit) {
|
||
logger.info("注册 WebSocket 会话: $sessionId")
|
||
sessionCallbacks[sessionId] = callback
|
||
sessionSubscriptions[sessionId] = mutableSetOf()
|
||
}
|
||
|
||
/**
|
||
* 注销会话
|
||
*/
|
||
fun unregisterSession(sessionId: String) {
|
||
logger.info("注销 WebSocket 会话: $sessionId")
|
||
|
||
// 取消所有订阅
|
||
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
||
channels.forEach { channel ->
|
||
unsubscribe(sessionId, channel)
|
||
}
|
||
|
||
sessionCallbacks.remove(sessionId)
|
||
}
|
||
|
||
/**
|
||
* 订阅频道
|
||
*/
|
||
fun subscribe(sessionId: String, channel: String, payload: Map<*, *>?) {
|
||
logger.info("订阅频道: $sessionId -> $channel")
|
||
|
||
// 记录订阅关系
|
||
sessionSubscriptions.getOrPut(sessionId) { mutableSetOf() }.add(channel)
|
||
channelSubscriptions.getOrPut(channel) { mutableSetOf() }.add(sessionId)
|
||
|
||
// 发送订阅确认
|
||
sendSubscribeAck(sessionId, channel, true)
|
||
|
||
// 根据频道类型启动推送服务
|
||
when (channel) {
|
||
"position" -> {
|
||
positionPushService.subscribe(sessionId) { message ->
|
||
pushData(sessionId, channel, message)
|
||
}
|
||
}
|
||
"order" -> {
|
||
orderPushService.subscribe(sessionId, payload) { message ->
|
||
pushData(sessionId, channel, message)
|
||
}
|
||
}
|
||
else -> {
|
||
logger.warn("未知的频道: $channel")
|
||
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消订阅
|
||
*/
|
||
fun unsubscribe(sessionId: String, channel: String) {
|
||
logger.info("取消订阅频道: $sessionId -> $channel")
|
||
|
||
// 移除订阅关系
|
||
sessionSubscriptions[sessionId]?.remove(channel)
|
||
channelSubscriptions[channel]?.remove(sessionId)
|
||
|
||
// 如果频道没有订阅者了,停止推送服务
|
||
if (channelSubscriptions[channel]?.isEmpty() == true) {
|
||
when (channel) {
|
||
"position" -> positionPushService.unsubscribe(sessionId)
|
||
"order" -> orderPushService.unsubscribe(sessionId)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 推送数据到指定会话
|
||
*/
|
||
private fun pushData(sessionId: String, channel: String, payload: Any) {
|
||
val callback = sessionCallbacks[sessionId]
|
||
if (callback != null) {
|
||
val message = WebSocketMessage(
|
||
type = WebSocketMessageType.DATA,
|
||
channel = channel,
|
||
payload = payload,
|
||
timestamp = System.currentTimeMillis()
|
||
)
|
||
callback(message)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送订阅确认
|
||
*/
|
||
private fun sendSubscribeAck(sessionId: String, channel: String, success: Boolean, errorMessage: String? = null) {
|
||
val callback = sessionCallbacks[sessionId]
|
||
if (callback != null) {
|
||
val message = WebSocketMessage(
|
||
type = WebSocketMessageType.SUB_ACK.value,
|
||
channel = channel,
|
||
status = if (success) 0 else 1, // 0: success, 非0: error
|
||
message = errorMessage
|
||
)
|
||
callback(message)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.4 频道推送服务接口
|
||
|
||
**文件**: `backend/src/main/kotlin/com/wrbug/polymarketbot/service/PositionPushService.kt`
|
||
|
||
需要修改 `PositionPushService`,添加订阅接口:
|
||
|
||
```kotlin
|
||
/**
|
||
* 订阅仓位推送
|
||
*/
|
||
fun subscribe(sessionId: String, callback: (PositionPushMessage) -> Unit) {
|
||
// 注册回调
|
||
clientCallbacks[sessionId] = callback
|
||
|
||
// 如果是第一个订阅者,启动轮询
|
||
if (clientCallbacks.size == 1) {
|
||
startPolling()
|
||
}
|
||
|
||
// 立即发送全量数据
|
||
scope.launch {
|
||
sendFullData(sessionId)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消订阅仓位推送
|
||
*/
|
||
fun unsubscribe(sessionId: String) {
|
||
clientCallbacks.remove(sessionId)
|
||
|
||
// 如果没有订阅者了,停止轮询
|
||
if (clientCallbacks.isEmpty()) {
|
||
stopPolling()
|
||
}
|
||
}
|
||
```
|
||
|
||
## 6. 配置更新
|
||
|
||
### 6.1 WebSocket 配置
|
||
|
||
**文件**: `backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt`
|
||
|
||
```kotlin
|
||
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
||
// 统一 WebSocket 端点
|
||
registry.addHandler(unifiedWebSocketHandler, "/ws")
|
||
.setAllowedOrigins("*")
|
||
}
|
||
```
|
||
|
||
### 6.2 配置文件
|
||
|
||
**文件**: `backend/src/main/resources/application.properties`
|
||
|
||
```properties
|
||
# WebSocket 配置
|
||
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
|
||
```
|
||
|
||
## 7. 迁移计划
|
||
|
||
### 7.1 阶段一:后端重构
|
||
1. 创建统一 WebSocket 处理器
|
||
2. 创建订阅管理服务
|
||
3. 重构 PositionPushService 支持订阅接口
|
||
4. 更新 WebSocket 配置
|
||
|
||
### 7.2 阶段二:前端重构
|
||
1. 创建全局 WebSocket 管理器
|
||
2. 创建 React Hook
|
||
3. 重构 PositionList 页面使用新接口
|
||
4. 移除旧的 WebSocket 连接代码
|
||
|
||
### 7.3 阶段三:测试验证
|
||
1. 测试订阅/取消订阅
|
||
2. 测试多订阅者场景
|
||
3. 测试重连机制
|
||
4. 测试数据分发
|
||
|
||
## 8. 优势
|
||
|
||
1. **统一管理**:所有推送服务共用一个连接,减少资源消耗
|
||
2. **易于扩展**:添加新频道只需实现推送服务接口
|
||
3. **避免重复**:前端统一管理,避免重复订阅
|
||
4. **数据分发**:后端推送一次,前端分发到多个订阅者
|
||
5. **可维护性**:清晰的架构,易于维护和调试
|
||
|
||
## 9. 注意事项
|
||
|
||
1. **消息格式**:确保前后端消息格式一致
|
||
2. **错误处理**:完善的错误处理和日志记录
|
||
3. **性能优化**:大量订阅者时注意性能
|
||
4. **向后兼容**:迁移时保持向后兼容
|
||
5. **测试覆盖**:充分测试各种场景
|
||
|