From 5367b3b4d92588c4e76728e6bd4ad6aae0cbb967 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 23 七月 2026 13:05:25 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0

---
 src/views/im/home/store/websocketStore.ts | 1259 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,259 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts
new file mode 100644
index 0000000..f948bcd
--- /dev/null
+++ b/src/views/im/home/store/websocketStore.ts
@@ -0,0 +1,1259 @@
+import type {
+  Group,
+  ImGroupMessageNotification,
+  ImMessageReadNotification,
+  ImMessageReceiptNotification,
+  ImNoConversationNotification,
+  ImNotificationWebSocketDTO,
+  ImPrivateMessageNotification,
+  Message,
+  WebSocketFrame
+} from '../types'
+
+import type { ImChannelMessageApi } from '#/api/im/message/channel'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
+import { readGroupMessages as apiReadGroupMessages } from '#/api/im/message/group'
+import { readPrivateMessages as apiReadPrivateMessages } from '#/api/im/message/private'
+import { getCurrentUserId, getRefreshToken } from '#/views/im/utils/auth'
+
+import { buildChannelConversationStub } from '../../utils/channel'
+import {
+  MESSAGE_GROUP_READ_ENABLED,
+  MESSAGE_PRIVATE_READ_ENABLED,
+  WS_RECONNECT_BASE_MS,
+  WS_RECONNECT_JITTER_MS,
+  WS_RECONNECT_MAX_MS
+} from '../../utils/config'
+import {
+  ImContentType,
+  ImConversationType,
+  ImMessageReceiptStatus,
+  ImMessageStatus,
+  ImRtcCallMediaType,
+  ImRtcParticipantStatus,
+  ImWebSocketMessageType,
+  isFriendChatTip,
+  isFriendNotification,
+  isGroupRequestNotification,
+  isNormalMessage
+} from '../../utils/constants'
+import {
+  getPrivateMessagePeerId,
+  parseRtcCallPayload,
+  playAudioTip,
+  resolveCallEndReasonText
+} from '../../utils/message'
+import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+import { type FriendNotificationPayload, useFriendStore } from './friendStore'
+import { useGroupRequestStore } from './groupRequestStore'
+import { useGroupStore } from './groupStore'
+import { useMessageStore } from './messageStore'
+import {
+  type ImRtcCallEndNotification,
+  type ImRtcCallNotification,
+  type ImRtcParticipantConnectedNotification,
+  type ImRtcParticipantDisconnectedNotification,
+  useRtcStore
+} from './rtcStore'
+
+/** FRIEND_DELETE 甯� payload 鏄惁甯� clear=true锛歝lear 璇箟鏄竻浼氳瘽鏈韩锛岃烦杩囨皵娉℃覆鏌� */
+const isFriendDeleteWithClear = (frame: ImPrivateMessageNotification): boolean => {
+  if (frame.type !== ImContentType.FRIEND_DELETE) {
+    return false
+  }
+  try {
+    const payload = JSON.parse(frame.content || '{}') as { clear?: boolean }
+    return payload.clear === true
+  } catch {
+    return false
+  }
+}
+
+/** 浠庣鑱婃秷鎭抚瑙f瀽濂藉弸閫氱煡 payload */
+const parseFriendNotificationPayload = (
+  frame: ImPrivateMessageNotification
+): FriendNotificationPayload => JSON.parse(frame.content || '{}') as FriendNotificationPayload
+
+/** 绉佽亰娑堟伅甯ф槸鍚﹀彲鎺ㄦ柇濂藉弸瀵圭 */
+const isPrivateMessageNotification = (
+  frame: ImNoConversationNotification | ImPrivateMessageNotification
+): frame is ImPrivateMessageNotification => 'senderId' in frame && 'receiverId' in frame
+
+const RTC_LIVEKIT_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:'])
+const RTC_MEDIA_TYPES = new Set<number>(Object.values(ImRtcCallMediaType))
+
+/** 蹇界暐鏅�氬疄鏃跺抚鎸佷箙鍖栧け璐� */
+function ignoreRealtimePersistError(promise: Promise<void>): void {
+  void promise.catch(() => undefined)
+}
+
+interface WebSocketListenerSet {
+  close: (event: CloseEvent) => void
+  error: (event: Event) => void
+  message: (event: MessageEvent) => void
+  open: (event: Event) => void
+}
+
+const websocketListenerMap = new WeakMap<WebSocket, WebSocketListenerSet>()
+
+/** 缁戝畾 WebSocket 浜嬩欢 */
+function bindWebSocketListeners(socket: WebSocket, listeners: WebSocketListenerSet): void {
+  websocketListenerMap.set(socket, listeners)
+  socket.addEventListener('open', listeners.open)
+  socket.addEventListener('message', listeners.message)
+  socket.addEventListener('close', listeners.close)
+  socket.addEventListener('error', listeners.error)
+}
+
+/** 瑙g粦 WebSocket 浜嬩欢 */
+function unbindWebSocketListeners(socket: WebSocket): void {
+  const listeners = websocketListenerMap.get(socket)
+  if (!listeners) {
+    return
+  }
+  socket.removeEventListener('open', listeners.open)
+  socket.removeEventListener('message', listeners.message)
+  socket.removeEventListener('close', listeners.close)
+  socket.removeEventListener('error', listeners.error)
+  websocketListenerMap.delete(socket)
+}
+
+/** 鏍¢獙 LiveKit 杩炴帴鍦板潃 */
+function isValidLiveKitUrl(url?: string): boolean {
+  if (!url) {
+    return false
+  }
+  try {
+    return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol)
+  } catch {
+    return false
+  }
+}
+
+/** 鏍¢獙鏉ョ數淇′护杞借嵎 */
+function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean {
+  if (!payload.room || !payload.token || !isValidLiveKitUrl(payload.livekitUrl)) {
+    return false
+  }
+  if (!RTC_MEDIA_TYPES.has(payload.mediaType) || !payload.inviterUserId) {
+    return false
+  }
+  if (payload.conversationType === ImConversationType.PRIVATE) {
+    return true
+  }
+  return payload.conversationType === ImConversationType.GROUP && !!payload.groupId
+}
+
+/**
+ * WebSocket 绉佽亰 DTO -> 鍓嶇 Message锛泃argetId 鏄細璇濅富閿紙瀵圭 userId锛�
+ * 涓嶅啓鍙戦�佷汉鍚嶅瓧娈碉細娓叉煋灞傝蛋 utils/user 瀹炴椂绠楋紙澶囨敞 / 缇ゆ樀绉板彉鏇村悗鍘嗗彶娑堟伅鑷姩鍒锋柊锛�
+ */
+const convertPrivateMessage = (
+  websocketMessage: ImPrivateMessageNotification,
+  currentUserId: number
+): Message => ({
+  id: websocketMessage.id,
+  clientMessageId: websocketMessage.clientMessageId,
+  type: websocketMessage.type,
+  content: websocketMessage.content,
+  status: websocketMessage.status,
+  receiptStatus: websocketMessage.receiptStatus,
+  sendTime: new Date(websocketMessage.sendTime).getTime(),
+  senderId: websocketMessage.senderId,
+  targetId: getPrivateMessagePeerId(websocketMessage, currentUserId),
+  selfSend: websocketMessage.senderId === currentUserId
+})
+
+/**
+ * WebSocket 缇よ亰 DTO -> 鍓嶇 Message
+ * 甯� atUserIds / receiverUserIds 缁� @ 鏍囪鍜屽畾鍚戞帴鏀剁敤锛�
+ * receiptStatus / readCount 璁╁绔悓姝ユ敹鍒拌嚜宸卞彂鐨勭兢娑堟伅鏃跺洖鎵� UI 绔嬪埢灏辨湁鏁版嵁
+ */
+const convertGroupMessage = (
+  websocketMessage: ImGroupMessageNotification,
+  currentUserId: number
+): Message => ({
+  id: websocketMessage.id,
+  clientMessageId: websocketMessage.clientMessageId,
+  type: websocketMessage.type,
+  content: websocketMessage.content,
+  status: websocketMessage.status,
+  sendTime: new Date(websocketMessage.sendTime).getTime(),
+  senderId: websocketMessage.senderId,
+  targetId: websocketMessage.groupId,
+  selfSend: websocketMessage.senderId === currentUserId,
+  atUserIds: websocketMessage.atUserIds || [],
+  receiverUserIds: websocketMessage.receiverUserIds || [],
+  receiptStatus: websocketMessage.receiptStatus,
+  readCount: websocketMessage.readCount
+})
+
+/**
+ * IM WebSocket Store
+ *
+ * 鑱岃矗锛堜笉鍙槸杩為�氫俊锛屼篃鏄悗绔� IM 浜嬩欢鐨勭粺涓�鍏ュ彛 鈫� 鑱斿姩 conversationStore / friendStore / groupStore锛夛細
+ *
+ * 1. 閾捐矾绠$悊锛氬缓杩� / 鏂繛 / 蹇冭烦淇濇椿 / 鑷姩閲嶈繛
+ * 2. 甯у垎鍙戯細dispatchFrame 鈫� dispatchPrivateFrame / dispatchGroupFrame锛屾寜浼氳瘽鍜屽唴瀹圭被鍨嬪垎娴�
+ * 3. 缂撳啿锛氬垵濮嬪寲鍔犺浇鏈燂紙conversationStore.loading=true锛夋殏瀛樻秷鎭紝绛� pull 瀹屾垚鍚庣敱 useMessagePuller 璋� flushBuffer 鍥炴斁
+ * 4. 浜嬩欢澶勭悊锛堟寜绫诲瀷鍒嗗彂鍒板搴� handle*锛岃仈鍔� conversation / friend / group store锛夛細
+ *    - 鏅�氭秷鎭紙TEXT / IMAGE / FILE / VOICE / VIDEO锛夛細鍏ュ簱 + 褰撳墠浼氳瘽鑷姩宸茶 / 鎻愮ず闊�
+ *    - 宸茶 / 鍥炴墽锛圧EAD / RECEIPT锛夛細澶氱宸茶鍚屾銆佸鏂硅鍚庡洖鎵�
+ *    - 濂藉弸鍙樻洿锛團RIEND_*锛夛細鍚屾 friendStore + 绾ц仈鍒锋柊绉佽亰浼氳瘽锛汧RIEND_ADD / FRIEND_DELETE 棰濆鎻掑叆浼氳瘽姘旀场
+ *    - 缇や釜浜轰俊鍙凤紙GROUP_MEMBER_SETTING_UPDATE锛夛細鍚屾 groupStore + 绾ц仈鍒锋柊缇よ亰浼氳瘽
+ *    - 缇ゆ垚鍛樻樀绉板彉鏇达紙GROUP_MEMBER_NICKNAME_UPDATE锛夛細鍚屾 groupStore锛屼笉鎻掑叆娑堟伅鍒楄〃
+ *    - 缇ゅ箍鎾簨浠讹紙GROUP_*锛夛細璧� handleGroupMessage + applyGroupNotification 鏃佽矾锛堝惈 DISSOLVE / QUIT / KICK 鑷垽娓呯兢锛�
+ */
+export const useImWebSocketStore = defineStore('imWebSocketStore', {
+  state: () => ({
+    socket: null as null | WebSocket,
+    isConnected: false,
+    reconnectTimer: null as null | ReturnType<typeof setTimeout>,
+    /** 杩炵画閲嶈繛澶辫触娆℃暟锛沷nopen 鎴愬姛 / disconnect 涓诲姩鏂紑鍚庢竻闆讹紝鐢ㄤ簬鎸囨暟閫�閬� */
+    reconnectAttempts: 0,
+    heartbeatTimer: null as null | ReturnType<typeof setInterval>,
+    messageBuffer: [] as Array<
+      | { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageApi.ChannelMessageRespVO }
+      | {
+          conversationType: typeof ImConversationType.GROUP
+          payload: ImGroupMessageNotification
+        }
+      | {
+          conversationType: typeof ImConversationType.PRIVATE
+          payload: ImPrivateMessageNotification
+        }
+    > // 鍒濆鍖栧姞杞芥湡鍐咃紝鍏堟妸鏅�氭秷鎭涪杩涚紦鍐插尯锛宲ull 瀹屾垚鍚庡啀涓�娆℃�у洖鏀�
+  }),
+
+  actions: {
+    /**
+     * 鍙栧嚭缂撳啿鍖烘秷鎭苟娓呯┖锛堢敱 useMessagePuller 鍦� pull 瀹屾垚鍚庤皟鐢紝缁熶竴鍥炴斁缁� conversationStore锛�
+     * 閰嶅悎 messageBuffer 瀹炵幇锛氬湪 conversationStore.loading 鏈熼棿鏀跺埌鐨� WS 娑堟伅鍏堟殏瀛橈紝閬垮厤鍜� pull 鐨� minId 娓告爣鎵撴灦
+     */
+    flushBuffer() {
+      const msgs = [...this.messageBuffer]
+      this.messageBuffer = []
+      return msgs
+    },
+
+    /** 鐩存帴涓㈠純缂撳啿甯т笉鍥炴斁锛坈ancelPull / 绂诲紑 IM 璋冪敤锛岄槻姝笅娆¤繘 IM 鎶婃棫 session 甯у洖鏀捐繘鏂� store锛� */
+    discardBuffer() {
+      this.messageBuffer = []
+    },
+
+    /**
+     * 杩炴帴 WebSocket
+     * 澶嶇敤 yudao 鍐呯疆 /infra/ws 閫氶亾锛屽悗绔�氳繃 sendObject(type, content) 涓嬪彂
+     *
+     * 璋冪敤濂戠害锛氬垏璐﹀彿 / token 鍒锋柊鍓嶅繀椤诲厛 `disconnect()` 鍐� `connect()`锛�
+     * 鏈柟娉曚笉鎰熺煡 token 鍙樺寲锛屾棫 socket 鍦� CONNECTING / OPEN 鐘舵�佷細鐩存帴澶嶇敤鏃� token锛屽彲鑳芥嬁鍒伴敊璇韩浠�
+     */
+    connect() {
+      // 閴存潈鐢� refreshToken锛堢敓鍛藉懆鏈熸洿闀匡紱access token 杩囨湡鍚庢湇鍔$浼氶�氳繃 frame 閫氱煡閲嶇櫥锛�
+      const refreshToken = getRefreshToken()
+      if (!refreshToken) {
+        console.warn('[IM WS] refreshToken 涓虹┖锛岃烦杩囪繛鎺�')
+        return
+      }
+      // 鏃� socket 杩樺湪 CONNECTING / OPEN 鐩存帴澶嶇敤锛岄伩鍏嶅彔鍔犲浠� onmessage 鐩戝惉瀵艰嚧閲嶅娑堟伅 / 鎻愮ず闊� / 宸茶涓婃姤
+      const existingSocket = this.socket
+      if (
+        existingSocket &&
+        (existingSocket.readyState === WebSocket.OPEN ||
+          existingSocket.readyState === WebSocket.CONNECTING)
+      ) {
+        return
+      }
+      // 鏃� socket 宸� CLOSING / CLOSED锛氳В缁戝洖璋� + 娓呭紩鐢ㄥ啀 new锛岄伩鍏嶈�� handler 浠嶆寔鏈� store 寮曠敤闃荤 GC
+      if (existingSocket) {
+        unbindWebSocketListeners(existingSocket)
+        this.socket = null
+      }
+      const url = `${this.buildWsUrl()}/infra/ws?token=${refreshToken}`
+      const socket = new WebSocket(url)
+      this.socket = socket
+
+      bindWebSocketListeners(socket, {
+        // 杩炴帴寤虹珛锛氭爣璁颁笂绾� + 鍚姩蹇冭烦淇濇椿锛涢噸杩為��閬胯鏁板綊闆�
+        open: () => {
+          this.isConnected = true
+          this.reconnectAttempts = 0
+          console.log('[IM WS] connected')
+          this.startHeartbeat()
+        },
+
+        // 鏀跺埌甯э細'pong' 鏄績璺冲簲绛旂洿鎺ュ悶鎺夛紱鍏朵綑鎸� WebSocketFrame 瑙f瀽鍚庝氦缁� dispatchFrame 鍒嗘祦
+        message: (event) => {
+          if (event.data === 'pong') {
+            return
+          }
+          try {
+            const frame = JSON.parse(event.data) as WebSocketFrame
+            this.dispatchFrame(frame)
+          } catch (error) {
+            console.error('[IM WS] message parse error:', error)
+          }
+        },
+
+        // 鏈嶅姟绔叧闂� / 缃戠粶鏂細鏍囪涓嬬嚎锛屾寜鎸囨暟閫�閬胯嚜鍔ㄩ噸杩�
+        close: () => {
+          this.isConnected = false
+          console.log('[IM WS] disconnected')
+          this.reconnect()
+        },
+
+        // 寮傚父鏃朵笉涓诲姩 reconnect锛屼富鍔� close() 璁� close 鎴愪负鍞竴閲嶈繛鍏ュ彛
+        error: (error) => {
+          console.error('[IM WS] error:', error)
+          this.isConnected = false
+          this.socket?.close()
+        }
+      })
+    },
+
+    /** 鎷兼帴 WebSocket 鍩虹鍦板潃 */
+    buildWsUrl(): string {
+      // VITE_BASE_URL 鍙兘鏄� http:// 鎴� https:// 寮�澶达紝鏇挎崲鎴� ws:// 鎴� wss://锛涘鏋滄病閰嶇疆锛屽氨鐢ㄥ綋鍓嶉〉闈㈢殑鍗忚 + host
+      const baseUrl = (import.meta as any).env?.VITE_BASE_URL as string | undefined
+      if (baseUrl && baseUrl.length > 0) {
+        return baseUrl.replace(/^http/, 'ws')
+      }
+      // 褰撳墠椤甸潰鍗忚 + host锛堝 http://localhost:8080锛夛紝鏇挎崲鎴� ws://localhost:8080
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      const host = window.location.host
+      return `${protocol}//${host}`
+    },
+
+    /**
+     * 鎸� IM 閫氱煡甯у垎鍙�
+     */
+    dispatchFrame(frame: WebSocketFrame) {
+      if (frame.type !== ImWebSocketMessageType.NOTIFICATION) {
+        console.debug('[IM WS] 鏈瘑鍒簨浠�', frame)
+        return
+      }
+
+      const notification = this.safeParse(frame.content) as ImNotificationWebSocketDTO | null
+      if (!notification?.payload || !notification.contentType) {
+        return
+      }
+      const payload = {
+        ...notification.payload,
+        type: notification.contentType
+      }
+      switch (notification.conversationType) {
+        case ImConversationType.CHANNEL: {
+          this.dispatchChannelFrame(payload as ImChannelMessageApi.ChannelMessageRespVO)
+          break
+        }
+        case ImConversationType.GROUP: {
+          this.dispatchGroupFrame(payload as ImGroupMessageNotification)
+          break
+        }
+        case ImConversationType.NONE: {
+          this.dispatchNoConversationFrame(payload as ImNoConversationNotification)
+          break
+        }
+        case ImConversationType.PRIVATE: {
+          this.dispatchPrivateFrame(payload as ImPrivateMessageNotification)
+          break
+        }
+        default: {
+          console.debug('[IM WS] 鏈瘑鍒�氱煡', notification)
+        }
+      }
+    },
+
+    /**
+     * 鏃犱細璇濋�氱煡鍒嗗彂
+     */
+    dispatchNoConversationFrame(websocketMessage: ImNoConversationNotification) {
+      if (isFriendNotification(websocketMessage.type)) {
+        this.handleFriendNotification(websocketMessage)
+        return
+      }
+      if (isGroupRequestNotification(websocketMessage.type)) {
+        this.handleGroupRequestNotification(websocketMessage)
+        return
+      }
+      switch (websocketMessage.type) {
+        case ImContentType.RTC_CALL:
+        case ImContentType.RTC_PARTICIPANT_CONNECTED:
+        case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
+          this.handleRtcSignaling(websocketMessage)
+          break
+        }
+        default: {
+          console.debug('[IM WS] 鏈瘑鍒棤浼氳瘽閫氱煡', websocketMessage)
+        }
+      }
+    },
+
+    /**
+     * 棰戦亾甯у垎鍙戯細鎸� payload.type 鍒嗗埌 READ锛堝绔凡璇诲悓姝ワ級鎴栨櫘閫氱礌鏉愭帹閫�
+     */
+    dispatchChannelFrame(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO) {
+      if (websocketMessage.type === ImContentType.READ) {
+        this.handleChannelRead(websocketMessage)
+        return
+      }
+      ignoreRealtimePersistError(this.handleChannelMessage(websocketMessage))
+    },
+
+    /** 棰戦亾 READ锛氳嚜宸卞叾瀹冪粓绔湪鏌愰閬撻噷鏍囦负宸茶锛屾湰绔悓姝ユ竻闆惰棰戦亾鏈 */
+    handleChannelRead(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO) {
+      void useConversationStore()
+        .applyConversationReadList([
+          {
+            id: websocketMessage.id,
+            conversationType: ImConversationType.CHANNEL,
+            targetId: websocketMessage.channelId,
+            messageId: websocketMessage.id
+          }
+        ])
+        .catch((error) => console.warn('[IM WS] 棰戦亾宸茶鍚屾澶辫触', error))
+    },
+
+    /**
+     * 棰戦亾娑堟伅瀹炴椂鍏ヤ細璇濓紱棰戦亾娑堟伅鍗曞悜 + 鏃犵姸鎬佹満锛岀洿鎺� insertMessage 鍗冲彲
+     * pull 涓� WS 鎷垮埌鍚屼竴鏉� id 鏃讹紝messageStore.insertMessage 鍐呴儴鎸� id 鍘婚噸锛屼笉浼氶噸澶�
+     */
+    handleChannelMessage(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO): Promise<void> {
+      const conversationStore = useConversationStore()
+      const messageStore = useMessageStore()
+      // 绂荤嚎鍔犺浇鏈熼棿鍏堢紦鍐诧紝绛� pull 瀹屾垚鍚庡啀缁熶竴鍥炴斁锛岄伩鍏嶉噸澶嶆垨椤哄簭閿欎贡
+      if (conversationStore.loading) {
+        this.messageBuffer.push({
+          conversationType: ImConversationType.CHANNEL,
+          payload: websocketMessage
+        })
+        return Promise.resolve()
+      }
+      const sendTimeMs =
+        typeof websocketMessage.sendTime === 'number'
+          ? websocketMessage.sendTime
+          : new Date(websocketMessage.sendTime).getTime()
+      const conversation = conversationStore.getConversation(
+        ImConversationType.CHANNEL,
+        websocketMessage.channelId
+      )
+      const isActive =
+        conversationStore.activeConversation?.type === ImConversationType.CHANNEL &&
+        conversationStore.activeConversation?.targetId === websocketMessage.channelId
+      // 棰戦亾鍗曞悜璁㈤槄锛宺eceiptStatus 琛ㄨ揪銆屾垜鏄惁宸茶杩欐潯銆嶏細浼氳瘽鎵撳紑鍗冲凡璇� DONE锛屽惁鍒� PENDING锛堜笌 pull 鍙e緞涓�鑷达級
+      const persistPromise = messageStore.insertMessage(
+        buildChannelConversationStub(websocketMessage.channelId),
+        {
+          id: websocketMessage.id,
+          clientMessageId: '',
+          type: websocketMessage.type,
+          content: websocketMessage.content,
+          status: ImMessageStatus.NORMAL,
+          receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING,
+          sendTime: sendTimeMs,
+          senderId: 0,
+          targetId: websocketMessage.channelId,
+          selfSend: false,
+          materialId: websocketMessage.materialId
+        }
+      )
+      if (isActive) {
+        // 绐楀彛鎵撳紑 = 宸茶锛氭湰绔竻鏈 + 涓婃姤鏈嶅姟绔浣嶇疆锛岄伩鍏嶈浣嶇疆婊炲悗
+        const readReported = conversationStore.isReportedReadPositionCovered(
+          ImConversationType.CHANNEL,
+          websocketMessage.channelId,
+          websocketMessage.id
+        )
+        conversationStore.markConversationRead(
+          ImConversationType.CHANNEL,
+          websocketMessage.channelId,
+          websocketMessage.id
+        )
+        if (!readReported) {
+          apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
+            .then(() =>
+              conversationStore.markConversationReadReported(
+                ImConversationType.CHANNEL,
+                websocketMessage.channelId,
+                websocketMessage.id
+              )
+            )
+            .catch((error) => {
+              console.warn(
+                '[IM WS] 棰戦亾鑷姩宸茶涓婃姤澶辫触',
+                {
+                  conversationType: ImConversationType.CHANNEL,
+                  channelId: websocketMessage.channelId,
+                  messageId: websocketMessage.id
+                },
+                error
+              )
+            })
+        }
+      } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
+        // 闈炲綋鍓嶄細璇濅笖鏈厤鎵撴壈锛氬搷涓�涓嬫彁绀洪煶
+        playAudioTip()
+      }
+      return persistPromise
+    },
+
+    /** content 鏃㈠彲鑳藉凡鏄璞′篃鍙兘鏄� JSON 瀛楃涓诧紙鍚庣鐢� Map 搴忓垪鍖栦笅鍙戯級 */
+    safeParse(raw: unknown): null | Record<string, any> {
+      if (!raw) {
+        return null
+      }
+      if (typeof raw === 'object') {
+        return raw as Record<string, any>
+      }
+      try {
+        return JSON.parse(raw as string)
+      } catch (error) {
+        console.error('[IM WS] content 瑙f瀽澶辫触', error)
+        return null
+      }
+    },
+
+    // ==================== 鏅�氭秷鎭� ====================
+
+    /**
+     * 绉佽亰缁熶竴甯у垎鍙戯細鎸� payload.type锛圛mContentType锛夊垎鍒板凡璇� / 鍥炴墽 / 濂藉弸閫氱煡 / 鏅�氭秷鎭�
+     *
+     * 娑堟伅閫氱煡銆佸凡璇婚�氱煡銆佸洖鎵ч�氱煡鐢卞灞� contentType 缁熶竴鍒嗗彂
+     */
+    dispatchPrivateFrame(websocketMessage: ImPrivateMessageNotification) {
+      try {
+        switch (websocketMessage.type) {
+          case ImContentType.READ: {
+            this.handlePrivateRead(websocketMessage as ImMessageReadNotification)
+            break
+          }
+          case ImContentType.RECEIPT: {
+            this.handlePrivateReceipt(websocketMessage as ImMessageReceiptNotification)
+            break
+          }
+          case ImContentType.RTC_CALL_END: {
+            // 鍏ュ簱 + 鍏抽棴閫氳瘽绐� + 娓叉煋鑱婂ぉ tip锛堢鑱婂満鏅級
+            this.handleRtcCallEnd(websocketMessage)
+            ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
+            break
+          }
+          default: {
+            if (isFriendChatTip(websocketMessage.type)) {
+              this.handleFriendNotification(websocketMessage)
+              // FRIEND_DELETE 鐨� clear=true 璇箟鏄竻浼氳瘽鏈韩锛岃烦杩囨皵娉¢伩鍏嶅湪宸叉竻浼氳瘽閲屽啓鍏ヨ櫄鎷熸秷鎭�
+              if (!isFriendDeleteWithClear(websocketMessage)) {
+                ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
+              }
+            } else {
+              // TEXT / IMAGE / FILE / VOICE / VIDEO 绛夋櫘閫氭秷鎭�
+              ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
+            }
+          }
+        }
+      } catch (error) {
+        // 鍗曟潯甯х殑澶勭悊寮傚父涓嶅簲闃绘柇鍚庣画甯э紱鎵撳嵃瀹屾暣 websocketMessage 渚夸簬鎺掓煡
+        console.warn('[IM WS] dispatchPrivateFrame 澶勭悊澶辫触', websocketMessage, error)
+      }
+    },
+
+    /**
+     * 缇よ亰缁熶竴甯у垎鍙戯細鎸� payload.type锛圛mContentType锛夊垎鍒板凡璇� / 鍥炴墽 / 缇や釜浜轰俊鍙� / 鏅�氭秷鎭�
+     *
+     * GROUP_MEMBER_SETTING_UPDATE / GROUP_MEMBER_NICKNAME_UPDATE 鏄垚鍛樿祫鏂欏悓姝ヤ俊鍙凤紱鍏跺畠缇ゅ箍鎾簨浠惰蛋 handleGroupMessage 鍏ュ簱 + 瑙﹀彂 applyGroupNotification 鏃佽矾
+     */
+    dispatchGroupFrame(websocketMessage: ImGroupMessageNotification) {
+      try {
+        switch (websocketMessage.type) {
+          case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
+            this.handleGroupMemberNicknameUpdate(websocketMessage)
+            break
+          }
+          case ImContentType.GROUP_MEMBER_SETTING_UPDATE: {
+            this.handleGroupMemberSettingUpdate(websocketMessage)
+            break
+          }
+          case ImContentType.READ: {
+            this.handleGroupRead(websocketMessage as ImMessageReadNotification)
+            break
+          }
+          case ImContentType.RECEIPT: {
+            this.handleGroupReceipt(websocketMessage as ImMessageReceiptNotification)
+            break
+          }
+          case ImContentType.RTC_CALL_END: {
+            // 鍏ュ簱 + 绉婚櫎鑳跺泭鏉� + 鍏抽棴閫氳瘽绐楋紙濡傛灉褰撳墠鍦ㄨ缇ら�氳瘽鍐咃級
+            this.handleRtcCallEnd(websocketMessage)
+            ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
+            break
+          }
+          case ImContentType.RTC_CALL_START: {
+            // 鍏ュ簱 + 娓叉煋鑱婂ぉ tip锛涘悓鏃剁敤 START payload 鍏堢敓鎴愭渶灏忚兌鍥婃潯锛屽悗缁� getActiveCall / 鍙備笌鑰呬簨浠跺啀琛ラ綈鎴愬憳
+            this.handleRtcCallStart(websocketMessage)
+            ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
+            break
+          }
+          default: {
+            // TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 缇ゅ箍鎾簨浠�
+            ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
+          }
+        }
+      } catch (error) {
+        // 鍗曟潯甯х殑澶勭悊寮傚父涓嶅簲闃绘柇鍚庣画甯э紱鎵撳嵃瀹屾暣 websocketMessage 渚夸簬鎺掓煡
+        console.warn('[IM WS] dispatchGroupFrame 澶勭悊澶辫触', websocketMessage, error)
+      }
+    },
+
+    /**
+     * 绉佽亰鏅�氭秷鎭紙TEXT / IMAGE / FILE / VOICE / VIDEO锛夊叆搴� + 鑷姩宸茶
+     *
+     * 娴佺▼锛�
+     * 1. 绂荤嚎鍔犺浇鏈熺紦鍐诧紙閬垮紑涓� pull 鍥炲~鐨勭珵鎬侊級
+     * 2. 璁$畻 selfSend / peerId 缁村害锛屾媺濂藉弸淇℃伅鍥炲~灞曠ず瀛楁
+     * 3. 鎾ゅ洖 TIP 鐩存帴杞蛋 recallMessage锛屼笉杩涙秷鎭垪琛�
+     * 4. 鏋勯�犲墠绔� Message锛屾彃鍏ュ埌瀵瑰簲绉佽亰浼氳瘽
+     * 5. 褰撳墠浼氳瘽婵�娲绘椂鑷姩涓婃姤宸茶锛涘惁鍒欓潪鍏嶆墦鎵板搷鎻愮ず闊�
+     */
+    handlePrivateMessage(websocketMessage: ImPrivateMessageNotification): Promise<void> {
+      const conversationStore = useConversationStore()
+      const friendStore = useFriendStore()
+      const currentUserId = getCurrentUserId()
+
+      // 0. 闃插尽灞傦細senderId / receiverId 鍧囦笉鍚綋鍓嶇敤鎴风殑绉佽亰甯х洿鎺ヤ涪寮冿紝閬垮厤鍚庣璺敱 / 澶氱涓插彿姹℃煋浼氳瘽
+      //    锛團RIEND_* 绛夌郴缁熼�氱煡涔熻蛋杩欐潯閫氶亾锛屼絾 fromUserId=senderId銆乼oUserId=receiverId 浠嶆槸褰撳墠鐢ㄦ埛瑙嗚锛�
+      if (
+        currentUserId &&
+        websocketMessage.senderId !== currentUserId &&
+        websocketMessage.receiverId !== currentUserId
+      ) {
+        console.warn('[IM WS] 涓㈠純涓嶅睘浜庡綋鍓嶇敤鎴风殑绉佽亰甯�', websocketMessage)
+        return Promise.resolve()
+      }
+
+      // 1. 绂荤嚎鍔犺浇鏈熼棿鍏堢紦鍐诧紝绛� pull 瀹屾垚鍚庡啀缁熶竴鍥炴斁锛岄伩鍏嶉噸澶嶆垨椤哄簭閿欎贡
+      if (conversationStore.loading) {
+        this.messageBuffer.push({
+          conversationType: ImConversationType.PRIVATE,
+          payload: websocketMessage
+        })
+        return Promise.resolve()
+      }
+
+      // 2. selfSend / peerId锛氳嚜宸卞彂鐨勬秷鎭睘浜庛�屽彂缁� receiverId 鐨勪細璇濄�嶏紝鍒汉鍙戠殑灞炰簬銆屽彂閫佽�呯殑浼氳瘽銆�
+      const selfSend = websocketMessage.senderId === currentUserId
+      const peerId = getPrivateMessagePeerId(websocketMessage, currentUserId)
+      // 鏈煡瀵圭锛堥檶鐢熶汉鍔犲ソ鍙嬪墠鍏堟敹鍒版秷鎭瓑鍦烘櫙锛夛細寮傛琛ユ媺涓�娆★紝涓嬫鍐嶆覆鏌撳氨鏈� name/avatar
+      const friend = friendStore.getFriend(peerId)
+      if (!friend) {
+        friendStore.fetchFriendInfo(peerId).catch(() => undefined)
+      }
+      // 浼氳瘽鏍囬姘歌繙璺熴�屽绔�嶈蛋锛堜笉绠¤皝鍙戠殑娑堟伅锛夛紱杩欓噷鍙畻涓�娆$粰 insertMessage 鐢�
+      const peerDisplayName = friend ? getFriendDisplayName(friend) : ''
+
+      // 3. 鍚庣鎾ゅ洖锛氫笅鍙戜竴鏉� RECALL 娑堟伅锛宑ontent 涓� `{"messageId": xxx}`锛堝榻� ImContentTypeEnum.RECALL 鈫� RecallMessage锛�
+      // 杩欓噷鎷︽埅涓嬫潵鏀硅蛋 recallMessage锛堟妸鍘熸秷鎭洿鏂颁负 RECALL 鎬侊級锛屼笉璁╁畠浣滀负鏂版秷鎭繘鍒楄〃
+      if (websocketMessage.type === ImContentType.RECALL) {
+        return useMessageStore().recallMessage(
+          ImConversationType.PRIVATE,
+          peerId,
+          websocketMessage.content
+        )
+      }
+
+      // 4. 鍚庣 DTO 鈫� 鍓嶇 Message锛氬彂閫佷汉鍚嶆覆鏌撴椂瀹炴椂绠楋紝涓嶅啓鍏ユ秷鎭瓧娈�
+      const message = convertPrivateMessage(websocketMessage, currentUserId)
+      const persistPromise = useMessageStore().insertMessage(
+        {
+          type: ImConversationType.PRIVATE,
+          targetId: peerId,
+          name: peerDisplayName || String(peerId),
+          avatar: friend?.avatar || '',
+          silent: friend?.silent
+        },
+        message
+      )
+
+      // 5. 浠呭鏂规秷鎭墠璧般�岃嚜鍔ㄥ凡璇� / 鎻愮ず闊炽�嶅垎鏀細鑷繁鍙戠殑涓嶄細瑙﹀彂
+      if (!selfSend) {
+        const conversation = conversationStore.getConversation(ImConversationType.PRIVATE, peerId)
+        const isActive =
+          conversationStore.activeConversation?.type === ImConversationType.PRIVATE &&
+          conversationStore.activeConversation?.targetId === peerId
+        if (isActive) {
+          // 鑱婂ぉ绐楀彛鎵撳紑 = 瀹為檯鐪嬪埌浜嗭細鏈娓呮湭璇伙紱绉佽亰宸茶寮�鍚椂鍐嶄笂鎶ュ悗绔紝璁╁鏂� UI 绔嬪埢鍒囧埌"宸茶"
+          // 宸茶浣嶇疆鐩存帴鐢ㄥ垰鍒扮殑娑堟伅 id锛堣繖鏉″氨鏄綋鍓嶄細璇濇渶澶� id锛�
+          const readReported = conversationStore.isReportedReadPositionCovered(
+            ImConversationType.PRIVATE,
+            peerId,
+            websocketMessage.id
+          )
+          conversationStore.markConversationRead(
+            ImConversationType.PRIVATE,
+            peerId,
+            websocketMessage.id
+          )
+          if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
+            apiReadPrivateMessages(peerId, websocketMessage.id)
+              .then(() =>
+                conversationStore.markConversationReadReported(
+                  ImConversationType.PRIVATE,
+                  peerId,
+                  websocketMessage.id
+                )
+              )
+              .catch((error) => {
+                console.warn(
+                  '[IM WS] 绉佽亰鑷姩宸茶涓婃姤澶辫触',
+                  {
+                    conversationType: ImConversationType.PRIVATE,
+                    peerId,
+                    messageId: websocketMessage.id
+                  },
+                  error
+                )
+              })
+          }
+        } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
+          // 闈炲綋鍓嶄細璇濅笖鏈厤鎵撴壈锛氬搷涓�涓嬫彁绀洪煶锛堝甫鑺傛祦锛岃瑙� playAudioTip锛夛紱FRIEND_* 绛夌郴缁熶簨浠朵笉鍝�
+          playAudioTip()
+        }
+      }
+      return persistPromise
+    },
+
+    /** 绉佽亰 READ 浜嬩欢锛氳嚜宸辩殑鍏跺畠缁堢鍦ㄥ鏂逛細璇濋噷鏍囦负宸茶锛屾湰绔悓姝ユ竻闆舵湭璇伙紱绉佽亰宸茶鍏抽棴鏃跺厹搴曞拷鐣� */
+    handlePrivateRead(websocketMessage: ImMessageReadNotification) {
+      if (!MESSAGE_PRIVATE_READ_ENABLED) {
+        return
+      }
+      if (!websocketMessage.id || !websocketMessage.receiverId) {
+        return
+      }
+      void useConversationStore()
+        .applyConversationReadList([
+          {
+            id: websocketMessage.id,
+            conversationType: ImConversationType.PRIVATE,
+            targetId: websocketMessage.receiverId,
+            messageId: websocketMessage.id
+          }
+        ])
+        .catch((error) => console.warn('[IM WS] 绉佽亰宸茶鍚屾澶辫触', error))
+    },
+
+    /**
+     * 绉佽亰 RECEIPT 浜嬩欢锛氬鏂硅浜嗘垜鐨勬秷鎭紝鎶婂拰瀵规柟浼氳瘽閲岃嚜宸卞彂鐨勬秷鎭爣涓哄凡璇�
+     * 鍚庣灏� maxReadId 缂栫爜鍦ㄩ�氱煡鐨� id 瀛楁锛�
+     * 杩欓噷鎹鍗¤竟鐣岋紝閬垮厤鎶�"鍥炴墽鍦ㄨ矾涓婃椂鍒氬彂鐨勬秷鎭�"璇爣涓哄凡璇伙紱绉佽亰宸茶鍏抽棴鏃跺厹搴曞拷鐣�
+     */
+    handlePrivateReceipt(websocketMessage: ImMessageReceiptNotification) {
+      if (!MESSAGE_PRIVATE_READ_ENABLED) {
+        return
+      }
+      if (!websocketMessage.id) {
+        return
+      }
+      if (!websocketMessage.senderId) {
+        return
+      }
+      useMessageStore().applyMessageReadReceipt({
+        conversationType: ImConversationType.PRIVATE,
+        targetId: websocketMessage.senderId,
+        privateReadMaxId: websocketMessage.id
+      })
+    },
+
+    /**
+     * 缇よ亰鏅�氭秷鎭叆搴� + 鑷姩宸茶锛堢粨鏋勪笌 handlePrivateMessage 瀵圭О锛�
+     *
+     * 娴佺▼锛�
+     * 1. 绂荤嚎鍔犺浇鏈熺紦鍐�
+     * 2. 鏈煡缇ゆ椂鎷夌兢璇︽儏鍏滃簳
+     * 3. 鎾ゅ洖 TIP 鐩存帴杞蛋
+     * 4. 鏋勯�� Message + at 瀛楁锛屾彃鍏ュ埌瀵瑰簲缇よ亰浼氳瘽锛堝彂閫佷汉鍚嶆覆鏌撴椂瀹炴椂绠楋級
+     * 5. 褰撳墠浼氳瘽婵�娲绘椂鑷姩涓婃姤宸茶锛堝甫 lastMessageId锛夛紱鍚﹀垯闈炲厤鎵撴壈鍝嶆彁绀洪煶
+     */
+    handleGroupMessage(websocketMessage: ImGroupMessageNotification): Promise<void> {
+      const conversationStore = useConversationStore()
+      const groupStore = useGroupStore()
+      const currentUserId = getCurrentUserId()
+      const selfSend = websocketMessage.senderId === currentUserId
+
+      // 0. 闃插尽灞傦細瀹氬悜缇ゆ秷鎭� receiverUserIds 闈炵┖涓旀湭鍖呭惈褰撳墠鐢ㄦ埛鏃朵涪寮�
+      //    鑷繁鍙戠殑锛坰elfSend锛夊缁堥�氳繃锛涘叏鍛樺彲瑙侊紙receiverUserIds 涓虹┖ / 缂哄け锛変篃閫氳繃
+      const receiverUserIds = websocketMessage.receiverUserIds
+      if (
+        currentUserId &&
+        !selfSend &&
+        Array.isArray(receiverUserIds) &&
+        receiverUserIds.length > 0 &&
+        !receiverUserIds.includes(currentUserId)
+      ) {
+        console.warn('[IM WS] 涓㈠純涓嶅睘浜庡綋鍓嶇敤鎴风殑瀹氬悜缇ゆ秷鎭�', websocketMessage)
+        return Promise.resolve()
+      }
+
+      // 1. 绂荤嚎鍔犺浇鏈熺紦鍐诧紙涓庣鑱婂绉帮級
+      if (conversationStore.loading) {
+        this.messageBuffer.push({
+          conversationType: ImConversationType.GROUP,
+          payload: websocketMessage
+        })
+        return Promise.resolve()
+      }
+
+      // 2. 鏈煡缇ゆ椂鑷姩鎷夌兢璇︽儏 + 鎴愬憳锛堣鎷夊叆缇や絾杩樻病鏀跺埌 GROUP_CREATE 鏃剁殑鍏滃簳锛�
+      const group = groupStore.getGroup(websocketMessage.groupId)
+      if (!group) {
+        groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
+      }
+
+      // 3. 鍚庣鎾ゅ洖锛氫笅鍙戜竴鏉� RECALL 娑堟伅锛宑ontent 涓� `{"messageId": xxx}`
+      // 杩欓噷鎷︽埅涓嬫潵鏀硅蛋 recallMessage锛堟妸鍘熸秷鎭洿鏂颁负 RECALL 鎬侊級
+      if (websocketMessage.type === ImContentType.RECALL) {
+        return useMessageStore().recallMessage(
+          ImConversationType.GROUP,
+          websocketMessage.groupId,
+          websocketMessage.content
+        )
+      }
+
+      // 4. 鍚庣 DTO 鈫� 鍓嶇 Message锛氬彂閫佷汉鍚嶆覆鏌撴椂瀹炴椂绠楋紝涓嶅啓鍏ユ秷鎭瓧娈�
+      const message = convertGroupMessage(websocketMessage, currentUserId)
+      const persistPromise = useMessageStore().insertMessage(
+        {
+          type: ImConversationType.GROUP,
+          targetId: websocketMessage.groupId,
+          name: group ? getGroupDisplayName(group) : String(websocketMessage.groupId),
+          avatar: group?.avatar || '',
+          silent: group?.silent
+        },
+        message
+      )
+
+      // 5. 浠呭鏂规秷鎭墠璧般�岃嚜鍔ㄥ凡璇� / 鎻愮ず闊炽�嶏紙涓庣鑱婂绉帮級
+      if (!selfSend) {
+        const conversation = conversationStore.getConversation(
+          ImConversationType.GROUP,
+          websocketMessage.groupId
+        )
+        const isActive =
+          conversationStore.activeConversation?.type === ImConversationType.GROUP &&
+          conversationStore.activeConversation?.targetId === websocketMessage.groupId
+        if (isActive) {
+          // 缇ゅ凡璇讳笂鎶ラ渶瑕佸甫 messageId锛堢兢娑堟伅浠�"璇诲埌绗嚑鏉�"鐨勬父鏍囦负鍑嗭紝鍖哄埆浜庣鑱婂彧鏍� receiverId锛夛紱缇ゅ凡璇诲叧闂椂浠呮湰鍦版竻闆�
+          const readReported = conversationStore.isReportedReadPositionCovered(
+            ImConversationType.GROUP,
+            websocketMessage.groupId,
+            websocketMessage.id
+          )
+          conversationStore.markConversationRead(
+            ImConversationType.GROUP,
+            websocketMessage.groupId,
+            websocketMessage.id
+          )
+          if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
+            apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
+              .then(() =>
+                conversationStore.markConversationReadReported(
+                  ImConversationType.GROUP,
+                  websocketMessage.groupId,
+                  websocketMessage.id
+                )
+              )
+              .catch((error) => {
+                console.warn(
+                  '[IM WS] 缇よ亰鑷姩宸茶涓婃姤澶辫触',
+                  {
+                    conversationType: ImConversationType.GROUP,
+                    groupId: websocketMessage.groupId,
+                    messageId: websocketMessage.id
+                  },
+                  error
+                )
+              })
+          }
+        } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
+          // GROUP_* 缇ゅ箍鎾簨浠剁瓑绯荤粺娑堟伅涓嶅搷鎻愮ず闊�
+          playAudioTip()
+        }
+      }
+      return persistPromise
+    },
+
+    // ==================== 缇よ亰宸茶 / 鍥炴墽 ====================
+
+    /** 缇よ亰 READ锛氳嚜宸卞叾瀹冪粓绔湪鏌愮兢閲屾爣涓哄凡璇伙紝鏈鍚屾娓呴浂璇ョ兢鏈 + @ 绾㈠瓧锛涚兢宸茶鍏抽棴鏃跺厹搴曞拷鐣� */
+    handleGroupRead(websocketMessage: ImMessageReadNotification) {
+      if (!MESSAGE_GROUP_READ_ENABLED) {
+        return
+      }
+      const readMessageId = websocketMessage.readId || websocketMessage.id
+      if (!readMessageId || !websocketMessage.groupId) {
+        return
+      }
+      void useConversationStore()
+        .applyConversationReadList([
+          {
+            id: readMessageId,
+            conversationType: ImConversationType.GROUP,
+            targetId: websocketMessage.groupId,
+            messageId: readMessageId
+          }
+        ])
+        .catch((error) => console.warn('[IM WS] 缇よ亰宸茶鍚屾澶辫触', error))
+    },
+
+    /** 缇よ亰 RECEIPT锛氭洿鏂版煇鏉$兢娑堟伅鐨� readCount / receiptStatus锛涚兢宸茶鍏抽棴鏃跺厹搴曞拷鐣� */
+    handleGroupReceipt(websocketMessage: ImMessageReceiptNotification) {
+      if (!MESSAGE_GROUP_READ_ENABLED) {
+        return
+      }
+      if (!websocketMessage.id || !websocketMessage.groupId) {
+        return
+      }
+      useMessageStore().applyMessageReadReceipt({
+        conversationType: ImConversationType.GROUP,
+        targetId: websocketMessage.groupId,
+        groupMessageId: websocketMessage.id,
+        readCount: websocketMessage.readCount,
+        receiptStatus: websocketMessage.receiptStatus
+      })
+    },
+
+    // ==================== 濂藉弸閫氱煡锛�1201-1210 娈典綅锛� ====================
+
+    /**
+     * 绠� FRIEND_ADD / FRIEND_DELETE 甯х殑銆屽绔� userId銆嶏細
+     *    becomeFriends 鍗曟潯鍏ュ簱鍚庡弻鏂规敹鍒板悓涓�浠� payload锛宲ayload.friendUserId 鍥哄畾鏄� toUserId锛屾湰绔湡姝g殑瀵圭瑕佷粠甯� sender / receiver 鍙嶆帹
+     */
+    computeFriendPeerId(frame: ImPrivateMessageNotification): number {
+      const currentUserId = getCurrentUserId()
+      return getPrivateMessagePeerId(frame, currentUserId)
+    },
+
+    /**
+     * 濂藉弸閫氱煡缁熶竴鍏ュ彛锛氭寜 type 鍒嗗彂鍒� friendStore 鍐呴儴 dispatcher
+     */
+    handleFriendNotification(
+      websocketMessage: ImNoConversationNotification | ImPrivateMessageNotification
+    ) {
+      const payload = isPrivateMessageNotification(websocketMessage)
+        ? parseFriendNotificationPayload(websocketMessage)
+        : (websocketMessage as unknown as FriendNotificationPayload)
+      const friendStore = useFriendStore()
+      switch (websocketMessage.type) {
+        case ImContentType.FRIEND_ADD: {
+          friendStore.applyFriendAddNotification(
+            payload,
+            isPrivateMessageNotification(websocketMessage)
+              ? this.computeFriendPeerId(websocketMessage)
+              : payload.friendUserId
+          )
+          break
+        }
+        case ImContentType.FRIEND_BLOCK: {
+          friendStore.applyFriendBlockNotification(payload)
+          break
+        }
+        case ImContentType.FRIEND_DELETE: {
+          friendStore.applyFriendDeleteNotification(
+            payload,
+            isPrivateMessageNotification(websocketMessage)
+              ? this.computeFriendPeerId(websocketMessage)
+              : payload.friendUserId
+          )
+          break
+        }
+        case ImContentType.FRIEND_INFO_UPDATED: {
+          friendStore.applyFriendInfoUpdatedNotification(payload)
+          break
+        }
+        case ImContentType.FRIEND_REQUEST_APPROVED: {
+          friendStore.applyFriendRequestApprovedNotification(payload)
+          break
+        }
+        case ImContentType.FRIEND_REQUEST_RECEIVED: {
+          friendStore.applyFriendRequestReceivedNotification(payload)
+          break
+        }
+        case ImContentType.FRIEND_REQUEST_REJECTED: {
+          friendStore.applyFriendRequestRejectedNotification(payload)
+          break
+        }
+        case ImContentType.FRIEND_UNBLOCK: {
+          friendStore.applyFriendUnblockNotification(payload)
+          break
+        }
+        case ImContentType.FRIEND_UPDATE: {
+          friendStore.applyFriendUpdateNotification(payload)
+          break
+        }
+        default: {
+          console.debug('[IM WS] 鏈瘑鍒ソ鍙嬮�氱煡', websocketMessage)
+        }
+      }
+    },
+
+    // ==================== 鍔犵兢鐢宠閫氱煡锛�1503 / 1505 / 1506锛� ====================
+
+    /**
+     * 鍔犵兢鐢宠閫氱煡缁熶竴鍏ュ彛锛氬垎鍙戝埌 groupRequestStore锛岄┍鍔ㄦí骞� + Drawer 鍚屾
+     */
+    handleGroupRequestNotification(websocketMessage: ImNoConversationNotification) {
+      const payload = websocketMessage as { requestId?: number }
+      if (!payload.requestId) {
+        return
+      }
+      const groupRequestStore = useGroupRequestStore()
+      switch (websocketMessage.type) {
+        case ImContentType.GROUP_REQUEST_APPROVED:
+        case ImContentType.GROUP_REQUEST_REJECTED: {
+          groupRequestStore.removeGroupRequestById(payload.requestId)
+          break
+        }
+        case ImContentType.GROUP_REQUEST_RECEIVED: {
+          groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined)
+          break
+        }
+        default: {
+          break
+        }
+      }
+    },
+
+    // ==================== 缇ゅ叧绯讳簨浠讹紙鎵胯浇浜庣兢鑱婇�氶亾锛屾寜 inner type 鍒嗘祦锛� ====================
+
+    /**
+     * GROUP_MEMBER_SETTING_UPDATE锛氬绔悓姝ユ垚鍛樹釜浜鸿缃彉鏇达紙silent / groupRemark锛�
+     *
+     * payload 鎼哄甫鍙樻洿瀛楁锛屾寜闈� null 瀛楁鐩存帴灞�閮ㄦ洿鏂帮紱鐪佷竴娆� fetchGroupMemberList 鎺ュ彛
+     */
+    handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageNotification) {
+      // content 瑙f瀽澶辫触鐢卞灞� dispatchGroupFrame 鐨� try-catch 鍏滃簳锛堝惈 websocketMessage 鎵撳嵃锛夛紝涓嶉噸澶� catch
+      const payload: { groupRemark?: string; silent?: boolean; } = JSON.parse(
+        websocketMessage.content || '{}'
+      )
+      const groupStore = useGroupStore()
+      const group = groupStore.getGroup(websocketMessage.groupId)
+      if (!group) {
+        return
+      }
+      const fields: Partial<Group> = {}
+      if (payload.silent != null) {
+        fields.silent = payload.silent
+      }
+      if (payload.groupRemark != null) {
+        fields.groupRemark = payload.groupRemark
+      }
+      if (Object.keys(fields).length > 0) {
+        groupStore.updateGroupFields(websocketMessage.groupId, fields)
+      }
+    },
+
+    /** GROUP_MEMBER_NICKNAME_UPDATE锛氬悓姝ユ垚鍛樺湪缇ら噷鐨勬樀绉� */
+    handleGroupMemberNicknameUpdate(websocketMessage: ImGroupMessageNotification) {
+      useGroupStore().applyGroupNotification(
+        websocketMessage.groupId,
+        websocketMessage.type,
+        websocketMessage.content
+      )
+    },
+
+    // ==================== 蹇冭烦 / 閲嶈繛 ====================
+
+    /** 蹇冭烦鍖咃細绾枃鏈� 'ping'锛屽搴旀湇鍔$ 'pong'锛堝悗绔繖灞傜敤绾瓧绗︿覆绾﹀畾锛岄伩鍏� JSON 瑙f瀽寮�閿�锛� */
+    sendHeartBeat() {
+      if (this.socket && this.isConnected) {
+        this.socket.send('ping')
+      }
+    },
+
+    /** 涓诲姩鏂紑锛堝垏鎹㈢敤鎴� / 閫�鍑虹櫥褰曟椂鐢級锛氬叧 socket + 鍋滃績璺� + 鍙栨秷寰呴噸杩� */
+    disconnect() {
+      if (this.socket) {
+        // close() 寮傛瑙﹀彂 onclose / onerror锛屽洖璋冮噷浼氭棤鏉′欢 reconnect锛�
+        // 涓诲姩鍏抽棴璺緞蹇呴』鍏堝叏閮ㄨВ缁戯紝鍚﹀垯 onclose 浼氬紩鍙戣嚜鍔ㄩ噸杩烇紝CONNECTING 鏈熼棿鐨� in-flight message 涔熷彲鑳借鑰� onmessage 鎶曢�掑埌 stale 涓婁笅鏂�
+        unbindWebSocketListeners(this.socket)
+        this.socket.close()
+        this.socket = null
+      }
+      // onclose 宸茶瑙g粦锛屼笉浼氬啀甯垜浠 isConnected=false锛岃繖閲屾墜鍔ㄥ浣�
+      this.isConnected = false
+      this.stopHeartbeat()
+      if (this.reconnectTimer) {
+        clearTimeout(this.reconnectTimer)
+        this.reconnectTimer = null
+      }
+      // 涓诲姩鏂紑锛堝垏璐﹀彿 / 閫�鍑猴級锛氭竻闆堕��閬胯鏁帮紝涓嬫 connect 閲嶆柊浠庢渶鐭棿闅旇捣绠�
+      this.reconnectAttempts = 0
+    },
+
+    /**
+     * 鑷姩閲嶈繛锛氭寚鏁伴��閬� base * 2^attempt锛堝皝椤� max锛�+ 0~jitter ms 闅忔満鍋忕Щ
+     *
+     * onclose 鏄敮涓�鍏ュ彛锛沷nerror 涓嶅啀璋冩湰鏂规硶锛堟祻瑙堝櫒瑙勮寖涓よ�呭繀鍚屾椂瑙﹀彂锛岄伩鍏嶈鏁� +2锛�
+     * 涓嶈娆℃暟涓婇檺锛岄鐜囧皝椤跺湪 WS_RECONNECT_MAX_MS锛堢害 30s锛夋寔缁噸璇曪紝鐩村埌閾捐矾鎭㈠鎴栦富鍔� disconnect
+     */
+    reconnect() {
+      this.stopHeartbeat()
+      if (this.reconnectTimer) {
+        clearTimeout(this.reconnectTimer)
+        this.reconnectTimer = null
+      }
+      const backoff = Math.min(
+        WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempts,
+        WS_RECONNECT_MAX_MS
+      )
+      const delay = backoff + Math.floor(Math.random() * WS_RECONNECT_JITTER_MS)
+      this.reconnectAttempts++
+      console.log(`[IM WS] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
+      this.reconnectTimer = setTimeout(() => {
+        this.connect()
+      }, delay)
+    },
+
+    /** 蹇冭烦 5 绉掍竴娆★紝淇濇椿 + 鎺㈡椿锛堥摼璺柇浜� onclose 浼氳Е鍙戯紝鐢� reconnect 鍏滃簳锛� */
+    startHeartbeat() {
+      if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
+      this.heartbeatTimer = setInterval(() => {
+        if (this.socket && this.isConnected) {
+          this.sendHeartBeat()
+        }
+      }, 5000)
+    },
+
+    /** 鍋滃績璺筹細disconnect / 閲嶈繛鍓嶈皟锛岄伩鍏嶈�� timer 鍦ㄦ柊 socket 涓婄户缁Е鍙� sendHeartBeat */
+    stopHeartbeat() {
+      if (this.heartbeatTimer) {
+        clearInterval(this.heartbeatTimer)
+        this.heartbeatTimer = null
+      }
+    },
+
+    // ==================== 瀹炴椂閫氳瘽淇′护鍒嗗彂 ====================
+
+    /**
+     * 閫氳瘽淇′护鍒嗗彂锛�1601 RTC_CALL锛堟寜 status 鍖哄垎 INVITING / JOINED / REJECTED / NO_ANSWER / LEFT锛�+ 1602 / 1603 鍙備笌鑰呭姞鍏� / 绂诲紑
+     * <p>
+     * 鍗曚竴 dispatcher锛屾寜 type 鍒嗗彂鍒� rtcStore
+     */
+    handleRtcSignaling(websocketMessage: ImNoConversationNotification) {
+      const rtcStore = useRtcStore()
+      switch (websocketMessage.type) {
+        case ImContentType.RTC_CALL: {
+          const payload = websocketMessage as unknown as ImRtcCallNotification
+          switch (payload.status) {
+            case ImRtcParticipantStatus.INVITING: {
+              if (!isValidRtcInvitePayload(payload)) {
+                console.warn('[IM WS] RTC_CALL invite payload 涓嶅悎娉�', payload)
+                return
+              }
+              // 褰撳墠宸插湪閫氳瘽涓細蹇界暐鏂版潵鐢碉紱鍚庣灞傞潰涔熶細鎷掔粷锛岃繖閲屾槸鍏滃簳
+              if (!rtcStore.isActive) {
+                rtcStore.showIncoming(payload)
+              }
+              break
+            }
+            case ImRtcParticipantStatus.JOINED:
+            case ImRtcParticipantStatus.LEFT: {
+              // ACCEPT / HUNGUP 鏆備笉闇�瑕佹湰绔澶栧搷搴旓紱rtcStore 鐘舵�佺敱 1602/1603 + END 缁存姢
+              break
+            }
+            case ImRtcParticipantStatus.NO_ANSWER: {
+              // 缇ら�氳瘽鍗曚汉鎸搩瓒呮椂锛涗俊浠ょ嫭绔嬩繚鐣欒涔夛紝澶勭悊涓� REJECTED 涓�鑷�
+              rtcStore.applyParticipantNoAnswer(payload)
+              break
+            }
+            case ImRtcParticipantStatus.REJECTED: {
+              rtcStore.applyParticipantRejected(payload)
+              break
+            }
+            default: {
+              console.warn('[IM WS] 鏈瘑鍒殑 RTC_CALL status', payload)
+            }
+          }
+          return
+        }
+        case ImContentType.RTC_PARTICIPANT_CONNECTED: {
+          const payload = websocketMessage as unknown as ImRtcParticipantConnectedNotification
+          if (payload?.room && payload.userId) {
+            rtcStore.applyParticipantConnected(payload)
+          }
+          return
+        }
+        case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
+          const payload = websocketMessage as unknown as ImRtcParticipantDisconnectedNotification
+          if (payload?.room && payload.userId) {
+            rtcStore.applyParticipantDisconnected(payload)
+          }
+        }
+      }
+    },
+
+    /** RTC_CALL_START 閫氳瘽寮�濮� */
+    handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
+      const payload = parseRtcCallPayload(websocketMessage.content)
+      if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
+        console.warn('[IM WS] RTC_CALL_START payload 涓嶅悎娉�', {
+          groupId: websocketMessage.groupId,
+          messageId: websocketMessage.id,
+          contentLength: websocketMessage.content?.length ?? 0
+        })
+        return
+      }
+      useRtcStore().setGroupCall({
+        room: payload.room,
+        groupId: websocketMessage.groupId,
+        mediaType: payload.mediaType,
+        inviterId: payload.inviterUserId,
+        joinedUserIds: [payload.inviterUserId],
+        inviteeIds: []
+      })
+    },
+
+    /**
+     * RTC_CALL_END 閫氳瘽缁撴潫锛涚鑱� + 缇よ亰閮借蛋杩欎竴鏉★紱payload 鎼哄甫 conversationType 鍖哄垎
+     * <p>
+     * 绉佽亰锛氬叧闂綋鍓嶉�氳瘽绐�
+     * 缇よ亰锛氱Щ闄よ兌鍥婃潯锛涘鏈鍦ㄨ缇ら�氳瘽鍐呭垯鍏抽棴閫氳瘽绐�
+     */
+    handleRtcCallEnd(
+      websocketMessage: ImGroupMessageNotification | ImPrivateMessageNotification
+    ) {
+      const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null
+      if (!payload?.room) {
+        return
+      }
+      const rtcStore = useRtcStore()
+      const isGroup = payload.conversationType === ImConversationType.GROUP
+      // 缇ら�氳瘽锛氱Щ闄ゅ搴旀埧闂寸殑鑳跺泭鏉�
+      const groupId = (websocketMessage as ImGroupMessageNotification).groupId
+      if (isGroup && groupId) {
+        rtcStore.removeGroupCall(groupId, payload.room)
+      }
+      // 閫氳瘽绐� / 鏉ョ數绐楁寚鍚戝悓涓� room 鏃跺叧闂細
+      //   RUNNING / INVITING 闃舵瀵规瘮 call.room锛汭NCOMING 闃舵瀵规瘮 incomingPayload.room
+      const matchCall = rtcStore.call?.room === payload.room
+      const matchIncoming = rtcStore.incomingPayload?.room === payload.room
+      if (rtcStore.isActive && (matchCall || matchIncoming)) {
+        const reasonText = resolveCallEndReasonText(payload.endReason)
+        console.info('[Call] end:', reasonText)
+        rtcStore.reset()
+      }
+    }
+  }
+})
+
+export const useImWebSocketStoreWithOut = () => {
+  return useImWebSocketStore()
+}
+
+// dev: 璁� Pinia 鐨� actions / state 鏀瑰姩鏀寔 HMR锛岄伩鍏嶆瘡娆℃敼 store 閮藉緱纭埛
+// 鍚﹀垯 Vite 鎶婃柊妯″潡鎺ㄤ笅鏉ュ悗锛岃�� store 瀹炰緥鐨� action 闂寘浠嶆寚鍚戞棫鍑芥暟浣�
+if (import.meta.hot) {
+  import.meta.hot.accept(acceptHMRUpdate(useImWebSocketStore, import.meta.hot))
+}

--
Gitblit v1.9.3