259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
/**
|
|
* 聊天 WebSocket 处理器
|
|
*
|
|
* 事件:
|
|
* - onConnected(userId)
|
|
* - onTyping(isTyping)
|
|
* - onSentence(index, text, total, done) ← AI 逐句推送
|
|
* - onVoiceReady(index, audioUrl)
|
|
* - onError(errorMessage)
|
|
*/
|
|
import { getApiConfig } from "@/core/net/config/api_config";
|
|
import { ExceptionHandler } from "@/core/errors";
|
|
import { AppEnvUtil, Logger } from "@/utils";
|
|
|
|
const log = new Logger("ChatWebSocket");
|
|
const RECONNECT_DELAY_MS = 3000;
|
|
|
|
export interface SentencePayload {
|
|
index: number;
|
|
text: string;
|
|
total: number;
|
|
done: boolean;
|
|
}
|
|
|
|
export interface PaywallStatusPayload {
|
|
locked: boolean;
|
|
showContent: boolean;
|
|
showUpgrade: boolean;
|
|
reason: string | null;
|
|
hint: string | null;
|
|
detail: Record<string, unknown> | null;
|
|
}
|
|
|
|
export class ChatWebSocket {
|
|
private ws: WebSocket | null = null;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private connectionUrl: string | null = null;
|
|
private disposed = false;
|
|
|
|
onConnected: ((userId: string) => void) | null = null;
|
|
onTyping: ((isTyping: boolean) => void) | null = null;
|
|
onSentence: ((payload: SentencePayload) => void) | null = null;
|
|
onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
|
|
onImage: ((url: string) => void) | null = null;
|
|
onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null;
|
|
onError: ((errorMessage: string) => void) | null = null;
|
|
|
|
constructor(
|
|
private readonly serverUrl: string,
|
|
private readonly token: string,
|
|
) {}
|
|
|
|
get isConnected(): boolean {
|
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
connect(): void {
|
|
if (this.disposed) return;
|
|
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) => {
|
|
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(),
|
|
RECONNECT_DELAY_MS,
|
|
);
|
|
}
|
|
};
|
|
} catch (e) {
|
|
logWebSocketError("✕ WS CONNECT FAILED", e);
|
|
this.onError?.(
|
|
ExceptionHandler.message(e, "WebSocket connection failed."),
|
|
);
|
|
}
|
|
}
|
|
|
|
sendMessage(content: string): boolean {
|
|
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;
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.disposed = true;
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
this.ws?.close();
|
|
this.ws = null;
|
|
this.connectionUrl = null;
|
|
}
|
|
|
|
private handleMessage(data: unknown): void {
|
|
if (typeof data !== "string") {
|
|
logWebSocketWarn("← WS MESSAGE IGNORED: non-string payload", data);
|
|
return;
|
|
}
|
|
let payload: {
|
|
type?: string;
|
|
userId?: string;
|
|
isTyping?: boolean;
|
|
index?: number;
|
|
text?: string;
|
|
total?: number;
|
|
done?: boolean;
|
|
audioUrl?: string;
|
|
error?: string;
|
|
data?: {
|
|
image?: {
|
|
type?: string | null;
|
|
url?: string | null;
|
|
};
|
|
lockDetail?: Partial<PaywallStatusPayload>;
|
|
};
|
|
};
|
|
try {
|
|
payload = JSON.parse(data);
|
|
} catch (e) {
|
|
logWebSocketError("← WS MESSAGE PARSE FAILED", e);
|
|
return;
|
|
}
|
|
switch (payload.type) {
|
|
case "connected":
|
|
if (payload.userId) this.onConnected?.(payload.userId);
|
|
break;
|
|
case "typing":
|
|
this.onTyping?.(payload.isTyping ?? false);
|
|
break;
|
|
case "sentence":
|
|
this.onSentence?.({
|
|
index: payload.index ?? 0,
|
|
text: payload.text ?? "",
|
|
total: payload.total ?? 0,
|
|
done: payload.done ?? false,
|
|
});
|
|
break;
|
|
case "voice_ready":
|
|
this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
|
|
break;
|
|
case "image": {
|
|
const url = payload.data?.image?.url;
|
|
if (url) this.onImage?.(url);
|
|
break;
|
|
}
|
|
case "paywall_status": {
|
|
const lockDetail = payload.data?.lockDetail;
|
|
this.onPaywallStatus?.({
|
|
locked: lockDetail?.locked ?? false,
|
|
showContent: lockDetail?.showContent ?? true,
|
|
showUpgrade: lockDetail?.showUpgrade ?? false,
|
|
reason: lockDetail?.reason ?? null,
|
|
hint: lockDetail?.hint ?? null,
|
|
detail: lockDetail?.detail ?? null,
|
|
});
|
|
break;
|
|
}
|
|
case "error":
|
|
this.onError?.(payload.error ?? "Unknown error");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildChatWebSocketUrl(serverUrl: string, token: string): string {
|
|
if (AppEnvUtil.isDevelopment()) return serverUrl;
|
|
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。 */
|
|
export function createChatWebSocket(token: string): ChatWebSocket {
|
|
const base = getApiConfig().wsUrl;
|
|
return new ChatWebSocket(base, token);
|
|
}
|