feat(chat): log websocket traffic
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
border-radius: var(--radius-lg, 12px);
|
border-radius: var(--radius-lg, 12px);
|
||||||
|
border-top-left-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--color-bubble-background, #fff);
|
background: var(--color-bubble-background, #fff);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* ImageBubble 图片气泡
|
* ImageBubble 图片气泡
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/widgets/image_bubble.dart(63 行)
|
|
||||||
*
|
|
||||||
* 支持:
|
* 支持:
|
||||||
* - base64 data URI 解码(`data:image/png;base64,...`)
|
* - base64 data URI 解码(`data:image/png;base64,...`)
|
||||||
* - 点击 → 打开全屏查看器
|
* - 点击 → 打开全屏查看器
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* MessageContent 消息内容容器
|
* MessageContent 消息内容容器
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/widgets/message_content.dart(43 行)
|
|
||||||
*
|
|
||||||
* 决定渲染 ImageBubble 还是 TextBubble:
|
* 决定渲染 ImageBubble 还是 TextBubble:
|
||||||
* - 有 imageUrl:渲染 ImageBubble(图片)
|
* - 有 imageUrl:渲染 ImageBubble(图片)
|
||||||
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
|
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid rgba(246, 87, 160, 0.2);
|
border: 1px solid rgba(246, 87, 160, 0.2);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
|
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
|
||||||
#ffffff;
|
#ffffff;
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
* - onError(errorMessage)
|
* - onError(errorMessage)
|
||||||
*/
|
*/
|
||||||
import { getApiConfig } from "@/core/net/config/api_config";
|
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 {
|
export interface SentencePayload {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -36,6 +39,7 @@ export interface PaywallStatusPayload {
|
|||||||
export class ChatWebSocket {
|
export class ChatWebSocket {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private connectionUrl: string | null = null;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
onConnected: ((userId: string) => void) | null = null;
|
onConnected: ((userId: string) => void) | null = null;
|
||||||
@@ -62,25 +66,53 @@ export class ChatWebSocket {
|
|||||||
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
|
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
|
||||||
try {
|
try {
|
||||||
const url = buildChatWebSocketUrl(this.serverUrl, this.token);
|
const url = buildChatWebSocketUrl(this.serverUrl, this.token);
|
||||||
|
this.connectionUrl = url;
|
||||||
|
logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`);
|
||||||
this.ws = new WebSocket(url);
|
this.ws = new WebSocket(url);
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
|
logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`);
|
||||||
// 连接成功;userId 由后端在第一条消息中下发
|
// 连接成功;userId 由后端在第一条消息中下发
|
||||||
};
|
};
|
||||||
this.ws.onmessage = (e) => this.handleMessage(e.data);
|
this.ws.onmessage = (e) => {
|
||||||
this.ws.onerror = () => this.onError?.("WebSocket error");
|
logWebSocketFrame("← WS MESSAGE", url, e.data);
|
||||||
this.ws.onclose = () => {
|
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) {
|
if (!this.disposed) {
|
||||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
this.reconnectTimer = setTimeout(
|
||||||
|
() => this.connect(),
|
||||||
|
RECONNECT_DELAY_MS,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logWebSocketError("✕ WS CONNECT FAILED", e);
|
||||||
this.onError?.(e instanceof Error ? e.message : String(e));
|
this.onError?.(e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(content: string): boolean {
|
sendMessage(content: string): boolean {
|
||||||
if (!this.isConnected) return false;
|
const payload = { type: "message", content };
|
||||||
this.ws?.send(JSON.stringify({ 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +124,14 @@ export class ChatWebSocket {
|
|||||||
}
|
}
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
this.connectionUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(data: unknown): void {
|
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: {
|
let payload: {
|
||||||
type?: string;
|
type?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -119,7 +155,8 @@ export class ChatWebSocket {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(data);
|
payload = JSON.parse(data);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logWebSocketError("← WS MESSAGE PARSE FAILED", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
@@ -175,6 +212,59 @@ function buildChatWebSocketUrl(serverUrl: string, token: string): string {
|
|||||||
return `${serverUrl}?token=${encodeURIComponent(token)}`;
|
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", "<redacted>");
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return url.replace(/([?&]token=)[^&]+/i, "$1<redacted>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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。 */
|
/** 默认创建实例:从 env 读取 WS URL。 */
|
||||||
export function createChatWebSocket(token: string): ChatWebSocket {
|
export function createChatWebSocket(token: string): ChatWebSocket {
|
||||||
const base = getApiConfig().wsUrl;
|
const base = getApiConfig().wsUrl;
|
||||||
|
|||||||
@@ -162,9 +162,6 @@ export function applyHttpSendOutput(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地游客消息额度读取 / 映射逻辑已停用:
|
|
||||||
// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user