From f80b5215a99e74d6a3e77b33088dd24b2d4d5e47 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 24 Jun 2026 10:48:36 +0800 Subject: [PATCH] feat(chat): log websocket traffic --- .../chat/components/image-bubble.module.css | 1 + src/app/chat/components/image-bubble.tsx | 2 - src/app/chat/components/message-content.tsx | 2 - .../private-message-card.module.css | 1 + src/core/net/chat-websocket.ts | 108 ++++++++++++++++-- src/stores/chat/chat-machine.helpers.ts | 3 - 6 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/app/chat/components/image-bubble.module.css b/src/app/chat/components/image-bubble.module.css index c5235cd5..170c833a 100644 --- a/src/app/chat/components/image-bubble.module.css +++ b/src/app/chat/components/image-bubble.module.css @@ -4,6 +4,7 @@ max-width: 220px; max-height: 220px; border-radius: var(--radius-lg, 12px); + border-top-left-radius: 0; overflow: hidden; cursor: pointer; background: var(--color-bubble-background, #fff); diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index e9559b41..d49dbc44 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -2,8 +2,6 @@ /** * ImageBubble 图片气泡 * - * 原始 Dart: lib/ui/chat/widgets/image_bubble.dart(63 行) - * * 支持: * - base64 data URI 解码(`data:image/png;base64,...`) * - 点击 → 打开全屏查看器 diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 6adddfe3..453a6093 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -2,8 +2,6 @@ /** * MessageContent 消息内容容器 * - * 原始 Dart: lib/ui/chat/widgets/message_content.dart(43 行) - * * 决定渲染 ImageBubble 还是 TextBubble: * - 有 imageUrl:渲染 ImageBubble(图片) * - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字) diff --git a/src/app/chat/components/private-message-card.module.css b/src/app/chat/components/private-message-card.module.css index 66821132..97f8f1d8 100644 --- a/src/app/chat/components/private-message-card.module.css +++ b/src/app/chat/components/private-message-card.module.css @@ -3,6 +3,7 @@ padding: 14px; border: 1px solid rgba(246, 87, 160, 0.2); border-radius: 18px; + border-top-left-radius: 0; background: linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)), #ffffff; diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 5c78af72..404ec459 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -11,7 +11,10 @@ * - onError(errorMessage) */ import { getApiConfig } from "@/core/net/config/api_config"; -import { AppEnvUtil } from "@/utils"; +import { AppEnvUtil, Logger } from "@/utils"; + +const log = new Logger("ChatWebSocket"); +const RECONNECT_DELAY_MS = 3000; export interface SentencePayload { index: number; @@ -36,6 +39,7 @@ export interface PaywallStatusPayload { export class ChatWebSocket { private ws: WebSocket | null = null; private reconnectTimer: ReturnType | null = null; + private connectionUrl: string | null = null; private disposed = false; onConnected: ((userId: string) => void) | null = null; @@ -62,25 +66,53 @@ export class ChatWebSocket { if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return; try { const url = buildChatWebSocketUrl(this.serverUrl, this.token); + this.connectionUrl = url; + logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`); this.ws = new WebSocket(url); this.ws.onopen = () => { + logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`); // 连接成功;userId 由后端在第一条消息中下发 }; - this.ws.onmessage = (e) => this.handleMessage(e.data); - this.ws.onerror = () => this.onError?.("WebSocket error"); - this.ws.onclose = () => { + this.ws.onmessage = (e) => { + logWebSocketFrame("← WS MESSAGE", url, e.data); + this.handleMessage(e.data); + }; + this.ws.onerror = () => { + logWebSocketError(`✕ WS ERROR ${redactWebSocketUrl(url)}`); + this.onError?.("WebSocket error"); + }; + this.ws.onclose = (event) => { + logWebSocketDebug(`↔ WS CLOSE ${redactWebSocketUrl(url)}`, { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + reconnectInMs: this.disposed ? null : RECONNECT_DELAY_MS, + }); if (!this.disposed) { - this.reconnectTimer = setTimeout(() => this.connect(), 3000); + this.reconnectTimer = setTimeout( + () => this.connect(), + RECONNECT_DELAY_MS, + ); } }; } catch (e) { + logWebSocketError("✕ WS CONNECT FAILED", e); this.onError?.(e instanceof Error ? e.message : String(e)); } } sendMessage(content: string): boolean { - if (!this.isConnected) return false; - this.ws?.send(JSON.stringify({ type: "message", content })); + const payload = { type: "message", content }; + if (!this.isConnected) { + logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload); + return false; + } + logWebSocketFrame( + "→ WS SEND", + this.connectionUrl ?? this.serverUrl, + payload, + ); + this.ws?.send(JSON.stringify(payload)); return true; } @@ -92,10 +124,14 @@ export class ChatWebSocket { } this.ws?.close(); this.ws = null; + this.connectionUrl = null; } private handleMessage(data: unknown): void { - if (typeof data !== "string") return; + if (typeof data !== "string") { + logWebSocketWarn("← WS MESSAGE IGNORED: non-string payload", data); + return; + } let payload: { type?: string; userId?: string; @@ -119,7 +155,8 @@ export class ChatWebSocket { }; try { payload = JSON.parse(data); - } catch { + } catch (e) { + logWebSocketError("← WS MESSAGE PARSE FAILED", e); return; } switch (payload.type) { @@ -175,6 +212,59 @@ function buildChatWebSocketUrl(serverUrl: string, token: string): string { return `${serverUrl}?token=${encodeURIComponent(token)}`; } +function redactWebSocketUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.searchParams.has("token")) { + parsed.searchParams.set("token", ""); + } + return parsed.toString(); + } catch { + return url.replace(/([?&]token=)[^&]+/i, "$1"); + } +} + +function logWebSocketFrame( + label: string, + url: string, + payload: unknown, +): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + const bodyLabel = label.startsWith("→") ? "request body" : "response body"; + log.debug( + `${label} ${redactWebSocketUrl(url)}${body ? `\n${bodyLabel}:\n${body}` : ""}`, + ); +} + +function logWebSocketDebug(message: string, payload?: unknown): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + log.debug(`${message}${body ? `\nbody:\n${body}` : ""}`); +} + +function logWebSocketWarn(message: string, payload?: unknown): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + log.warn(`${message}${body ? `\nbody:\n${body}` : ""}`); +} + +function logWebSocketError(message: string, payload?: unknown): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + log.error(`${message}${body ? `\nbody:\n${body}` : ""}`); +} + +function formatWebSocketLogValue(payload: unknown): string { + if (payload instanceof Error) { + return Logger.formatValue({ + message: payload.message, + stack: payload.stack, + }); + } + return Logger.formatValue(payload); +} + /** 默认创建实例:从 env 读取 WS URL。 */ export function createChatWebSocket(token: string): ChatWebSocket { const base = getApiConfig().wsUrl; diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index bab25ee1..39f6b46a 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -162,9 +162,6 @@ export function applyHttpSendOutput( }; } -// 本地游客消息额度读取 / 映射逻辑已停用: -// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。 - // ============================================================ // Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调 // ============================================================