gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
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:clear è¯­ä¹‰æ˜¯æ¸…会话本身,跳过气泡渲染 */
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
  }
}
/** ä»Žç§èŠæ¶ˆæ¯å¸§è§£æžå¥½å‹é€šçŸ¥ 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)
}
/** è§£ç»‘ 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;targetId æ˜¯ä¼šè¯ä¸»é”®ï¼ˆå¯¹ç«¯ 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):入库 + å½“前会话自动已读 / æç¤ºéŸ³
 *    - å·²è¯» / å›žæ‰§ï¼ˆREAD / RECEIPT):多端已读同步、对方读后回执
 *    - å¥½å‹å˜æ›´ï¼ˆFRIEND_*):同步 friendStore + çº§è”刷新私聊会话;FRIEND_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>,
    /** è¿žç»­é‡è¿žå¤±è´¥æ¬¡æ•°ï¼›onopen æˆåŠŸ / 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
        }
    > // åˆå§‹åŒ–加载期内,先把普通消息丢进缓冲区,pull å®ŒæˆåŽå†ä¸€æ¬¡æ€§å›žæ”¾
  }),
  actions: {
    /**
     * å–出缓冲区消息并清空(由 useMessagePuller åœ¨ pull å®ŒæˆåŽè°ƒç”¨ï¼Œç»Ÿä¸€å›žæ”¾ç»™ conversationStore)
     * é…åˆ messageBuffer å®žçŽ°ï¼šåœ¨ conversationStore.loading æœŸé—´æ”¶åˆ°çš„ WS æ¶ˆæ¯å…ˆæš‚存,避免和 pull çš„ minId æ¸¸æ ‡æ‰“æž¶
     */
    flushBuffer() {
      const msgs = [...this.messageBuffer]
      this.messageBuffer = []
      return msgs
    },
    /** ç›´æŽ¥ä¸¢å¼ƒç¼“冲帧不回放(cancelPull / ç¦»å¼€ 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 è§£æžåŽäº¤ç»™ 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
      // é¢‘道单向订阅,receiptStatus è¡¨è¾¾ã€Œæˆ‘是否已读这条」:会话打开即已读 DONE,否则 PENDING(与 pull å£å¾„一致)
      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 è§£æžå¤±è´¥', error)
        return null
      }
    },
    // ==================== æ™®é€šæ¶ˆæ¯ ====================
    /**
     * ç§èŠç»Ÿä¸€å¸§åˆ†å‘:按 payload.type(ImContentType)分到已读 / å›žæ‰§ / å¥½å‹é€šçŸ¥ / æ™®é€šæ¶ˆæ¯
     *
     * æ¶ˆæ¯é€šçŸ¥ã€å·²è¯»é€šçŸ¥ã€å›žæ‰§é€šçŸ¥ç”±å¤–层 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(ImContentType)分到已读 / å›žæ‰§ / ç¾¤ä¸ªäººä¿¡å· / æ™®é€šæ¶ˆæ¯
     *
     * 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 å‡ä¸å«å½“前用户的私聊帧直接丢弃,避免后端路由 / å¤šç«¯ä¸²å·æ±¡æŸ“会话
      //    ï¼ˆFRIEND_* ç­‰ç³»ç»Ÿé€šçŸ¥ä¹Ÿèµ°è¿™æ¡é€šé“,但 fromUserId=senderId、toUserId=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 æ¶ˆæ¯ï¼Œcontent ä¸º `{"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 éžç©ºä¸”未包含当前用户时丢弃
      //    è‡ªå·±å‘的(selfSend)始终通过;全员可见(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 æ¶ˆæ¯ï¼Œcontent ä¸º `{"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,payload.friendUserId å›ºå®šæ˜¯ toUserId,本端真正的对端要从帧 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 è§£æžå¤±è´¥ç”±å¤–层 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 è§£æžå¼€é”€ï¼‰ */
    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 å·²è¢«è§£ç»‘,不会再帮我们设 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 æ˜¯å”¯ä¸€å…¥å£ï¼›onerror ä¸å†è°ƒæœ¬æ–¹æ³•(浏览器规范两者必同时触发,避免计数 +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;INCOMING é˜¶æ®µå¯¹æ¯” 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))
}