From 513e444ec4a4b1342afbeddc90f68a8ad139aa0a Mon Sep 17 00:00:00 2001 From: Seven Date: Wed, 14 Jan 2026 01:47:30 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20@riwa/api-types=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=E8=87=B3=200.0.138?= =?UTF-8?q?=EF=BC=9B=E9=87=8D=E6=9E=84=20WebSocket=20=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=BB=A5=E4=BC=98=E5=8C=96=E8=AE=A2=E9=98=85=E5=92=8C=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E8=AE=A2=E9=98=85=E5=8A=9F=E8=83=BD=EF=BC=9B=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E8=AE=A2=E5=8D=95=E7=B0=BF=E7=BB=84=E4=BB=B6=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E7=9A=84=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- pnpm-lock.yaml | 12 +- src/tradingview/datafeed.ts | 29 +- src/tradingview/websocket.ts | 354 +++++++++++++++++++++- src/views/trade/components/order-book.vue | 68 +++-- src/views/trade/index.vue | 20 +- 6 files changed, 419 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index e9a64b7..918bfdd 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@elysiajs/eden": "^1.4.5", "@ionic/vue": "^8.7.11", "@ionic/vue-router": "^8.7.11", - "@riwa/api-types": "http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz", + "@riwa/api-types": "http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz", "@tailwindcss/vite": "^4.1.18", "@vee-validate/zod": "^4.15.1", "@vueuse/core": "^14.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d756c2b..88f91d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,8 +69,8 @@ importers: specifier: ^8.7.11 version: 8.7.11(@stencil/core@4.39.0)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) '@riwa/api-types': - specifier: http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz - version: '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))' + specifier: http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz + version: '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))' '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) @@ -2804,9 +2804,9 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz': - resolution: {tarball: http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz} - version: 0.0.135 + '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz': + resolution: {tarball: http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz} + version: 0.0.138 peerDependencies: '@elysiajs/eden': ^1.4.5 @@ -12161,7 +12161,7 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))': + '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))': dependencies: '@elysiajs/eden': 1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)) diff --git a/src/tradingview/datafeed.ts b/src/tradingview/datafeed.ts index 81ab90e..4407c17 100644 --- a/src/tradingview/datafeed.ts +++ b/src/tradingview/datafeed.ts @@ -205,18 +205,14 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed { console.log("[RWADatafeed]: subscribeBars", { symbol: symbolInfo.name, resolution, subscriberUID }); this.subscribers.set(subscriberUID, { callback: onTick, resolution, symbol: symbolInfo.name }); - const wsConnection = tradeWebSocket.getSocket(); - wsConnection.send({ - action: "subscribe", - channels: [{ - name: "bar", - symbol: symbolInfo.name, - resolution, - }], - }); + tradeWebSocket.subscribeChannel([{ + name: "bar", + symbol: symbolInfo.name, + resolution, + }]); - wsConnection.subscribe((message) => { + tradeWebSocket.subscribe((message) => { const data = message.data as any; if (data.type !== "bar") return; @@ -238,13 +234,10 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed { unsubscribeBars(subscriberUID: string): void { console.log("[RWADatafeed]: unsubscribeBars", subscriberUID); const subscriber = this.subscribers.get(subscriberUID); - const wsConnection = tradeWebSocket.getSocket(); - wsConnection.send({ - action: "unsubscribe", - channels: [{ - name: "bar", - symbol: subscriber?.symbol || "", - }], - }); + tradeWebSocket.unsubscribeChannel([{ + name: "bar", + symbol: subscriber?.symbol as string, + resolution: subscriber?.resolution as ResolutionString, + }]); } } diff --git a/src/tradingview/websocket.ts b/src/tradingview/websocket.ts index c10fd09..2a73ac3 100644 --- a/src/tradingview/websocket.ts +++ b/src/tradingview/websocket.ts @@ -1,30 +1,364 @@ +import type { Treaty } from "@elysiajs/eden"; import type { MarketDataStreaming } from "@/api/types"; import { client } from "@/api"; +interface SubscribeMessage { + action: "subscribe" | "unsubscribe" | "ping"; + channels: Array<{ + name: "depth" | "trades" | "ticker" | "bar"; + symbol: string; + resolution?: string; + [key: string]: any; + }>; +} + +type MessageData = any; + +interface MessageHandler { + id: string; + handler: (data: MessageData) => void; +} + export class TradeWebSocket { - public socket: MarketDataStreaming | null = null; + private socket: MarketDataStreaming | null = null; + private reconnectTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 3000; + private heartbeatInterval = 30000; // 30秒心跳 + private isManualClose = false; + private connectionPromise: Promise | null = null; + private connectionResolve: (() => void) | null = null; + private messageQueue: SubscribeMessage[] = []; + private messageHandlers: MessageHandler[] = []; + private subscriptions = new Set(); + constructor() { - if (!this.socket) { - this.socket = client.api.market_data.streaming.subscribe(); + this.connect(); + } + + /** + * 连接 WebSocket + */ + private connect() { + if (this.socket) { + return this.connectionPromise; } - this.socket.on("error", () => { - this.socket = null; + + // 创建连接 Promise + this.connectionPromise = new Promise((resolve) => { + this.connectionResolve = resolve; + }); + + try { this.socket = client.api.market_data.streaming.subscribe(); - }); - this.socket.on("open", () => { - console.log("TradeWebSocket connected"); + + this.socket.on("open", () => { + console.log("✅ TradeWebSocket connected"); + this.reconnectAttempts = 0; + this.startHeartbeat(); + + // 连接成功,通知等待的请求 + if (this.connectionResolve) { + this.connectionResolve(); + this.connectionResolve = null; + } + + // 处理消息队列 + this.processMessageQueue(); + + // 重新订阅之前的频道 + this.resubscribe(); + }); + + this.socket.on("message", (data) => { + this.handleMessage(data); + }); + + this.socket.on("error", (error: any) => { + console.error("❌ TradeWebSocket error:", error); + this.handleError(); + }); + + this.socket.on("close", () => { + console.log("🔌 TradeWebSocket closed"); + this.stopHeartbeat(); + this.socket = null; + this.connectionPromise = null; + + if (!this.isManualClose) { + this.reconnect(); + } + }); + } + catch (error) { + console.error("❌ Failed to create WebSocket:", error); + this.handleError(); + } + + return this.connectionPromise; + } + + /** + * 处理接收到的消息 + */ + private handleMessage(data: any) { + // 处理心跳响应 + if (data.type === "pong") { + return; + } + + // 分发消息给所有订阅者 + this.messageHandlers.forEach((handler) => { + try { + handler.handler(data); + } + catch (error) { + console.error("❌ Message handler error:", error); + } }); } - public getSocket(): MarketDataStreaming { - return this.socket!; + /** + * 错误处理 + */ + private handleError() { + this.stopHeartbeat(); + this.socket = null; + this.connectionPromise = null; + + if (!this.isManualClose) { + this.reconnect(); + } } + /** + * 重连逻辑 + */ + private reconnect() { + if (this.reconnectTimer || this.reconnectAttempts >= this.maxReconnectAttempts) { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error("❌ Max reconnect attempts reached"); + } + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.min(this.reconnectAttempts, 5); + + console.log(`🔄 Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + /** + * 启动心跳 + */ + private startHeartbeat() { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (this.socket && this.isConnected()) { + try { + this.socket.send({ action: "ping" }); + } + catch (error) { + console.error("❌ Heartbeat error:", error); + } + } + }, this.heartbeatInterval); + } + + /** + * 停止心跳 + */ + private stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + /** + * 处理消息队列 + */ + private processMessageQueue() { + if (!this.isConnected()) { + return; + } + + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (message) { + this.sendImmediate(message); + } + } + } + + /** + * 重新订阅所有频道 + */ + private resubscribe() { + if (this.subscriptions.size === 0) { + return; + } + + const channels = Array.from(this.subscriptions).map((sub) => { + const [name, symbol] = sub.split(":") as any[]; + return { name, symbol }; + }); + + if (channels.length > 0) { + this.send({ + action: "subscribe", + channels, + }); + } + } + + /** + * 检查是否已连接 + */ + public isConnected(): boolean { + return this.socket !== null && this.connectionResolve === null; + } + + /** + * 等待连接建立 + */ + public async waitForConnection(): Promise { + if (this.isConnected()) { + return Promise.resolve(); + } + + if (!this.connectionPromise) { + this.connect(); + } + + return this.connectionPromise!; + } + + /** + * 发送消息(立即发送) + */ + private sendImmediate(message: SubscribeMessage) { + if (!this.socket) { + console.warn("⚠️ Socket not connected, message queued"); + return; + } + + try { + this.socket.send(message); + + // 记录订阅状态 + if (message.action === "subscribe") { + message.channels.forEach((channel) => { + const key = channel.symbol ? `${channel.name}:${channel.symbol}` : channel.name; + this.subscriptions.add(key); + }); + } + else if (message.action === "unsubscribe") { + message.channels.forEach((channel) => { + const key = channel.symbol ? `${channel.name}:${channel.symbol}` : channel.name; + this.subscriptions.delete(key); + }); + } + } + catch (error) { + console.error("❌ Failed to send message:", error); + throw error; + } + } + + /** + * 发送消息(带队列) + */ + public async send(message: SubscribeMessage) { + // 等待连接建立 + await this.waitForConnection(); + + // 如果已连接,立即发送 + if (this.isConnected()) { + this.sendImmediate(message); + } + else { + // 否则加入队列 + this.messageQueue.push(message); + } + } + + /** + * 订阅消息 + */ + public subscribe(handler: (data: MessageData) => void): string { + const id = `handler_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + this.messageHandlers.push({ id, handler }); + return id; + } + + /** + * 取消订阅消息 + */ + public unsubscribe(id: string) { + const index = this.messageHandlers.findIndex(h => h.id === id); + if (index !== -1) { + this.messageHandlers.splice(index, 1); + } + } + + /** + * 订阅频道 + */ + public async subscribeChannel(channels: SubscribeMessage["channels"]) { + await this.send({ + action: "subscribe", + channels, + }); + } + + /** + * 取消订阅频道 + */ + public async unsubscribeChannel(channels: SubscribeMessage["channels"]) { + await this.send({ + action: "unsubscribe", + channels, + }); + } + + /** + * 关闭连接 + */ public closeSocket() { + this.isManualClose = true; + this.stopHeartbeat(); + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.socket) { this.socket.close(); this.socket = null; } + + this.connectionPromise = null; + this.messageQueue = []; + this.subscriptions.clear(); + } + + /** + * 重置并重新连接 + */ + public reset() { + this.isManualClose = false; + this.reconnectAttempts = 0; + this.closeSocket(); + this.connect(); } } diff --git a/src/views/trade/components/order-book.vue b/src/views/trade/components/order-book.vue index 4e3ec42..47c2a8d 100644 --- a/src/views/trade/components/order-book.vue +++ b/src/views/trade/components/order-book.vue @@ -1,16 +1,25 @@